From d25e0dc299619c348f12896585e92e8f8ffec1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Tue, 10 Dec 2024 09:53:54 +0100 Subject: [PATCH 1/2] fix: not able to open some conversations [WPB-11325] (#3721) --- .../notification/CallNotificationManager.kt | 35 ++++- .../com/wire/android/ui/WireActivity.kt | 17 +++ .../wire/android/ui/WireActivityViewModel.kt | 144 ++++++------------ .../wire/android/ui/calling/CallActivity.kt | 10 +- .../ui/calling/CallActivityViewModel.kt | 7 +- .../android/util/SwitchAccountObserver.kt | 56 +++++++ .../CallNotificationManagerTest.kt | 53 ++++++- .../android/ui/CallActivityViewModelTest.kt | 58 ++++++- .../android/ui/WireActivityViewModelTest.kt | 65 ++++++++ 9 files changed, 334 insertions(+), 111 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/util/SwitchAccountObserver.kt diff --git a/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt index 3b4d08f1bbe..8d6ffb9ea57 100644 --- a/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt @@ -28,14 +28,17 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.KaliumCoreLogic import com.wire.android.notification.NotificationConstants.INCOMING_CALL_ID_PREFIX import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.session.CurrentSessionResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow @@ -54,9 +57,10 @@ import javax.inject.Singleton @Singleton @Suppress("TooManyFunctions") class CallNotificationManager @Inject constructor( - private val context: Context, + context: Context, dispatcherProvider: DispatcherProvider, val builder: CallNotificationBuilder, + @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private val notificationManager = NotificationManagerCompat.from(context) @@ -83,8 +87,19 @@ class CallNotificationManager @Inject constructor( hideOutdatedIncomingCallNotifications(allCurrentCalls) // show current incoming call notifications appLogger.i("$TAG: showing ${newCalls.size} new incoming calls (all incoming calls: ${allCurrentCalls.size})") + + val currentSessionId = (coreLogic.getGlobalScope().session.currentSession() as? CurrentSessionResult.Success)?.let { + if (it.accountInfo.isValid()) it.accountInfo.userId else null + } newCalls.forEach { data -> - showIncomingCallNotification(data) + /** + * For now only show full screen intent for current session, as if shown for another session it will switch to that + * session and the user won't know that he/she is receiving a call as a different account. + * For calls that are not for the current session it will show the notification as a heads up notification. + * In the future we can implement showing on the incoming call screen as what account the user will answer + * or even give them the option to change it themselves on that screen. + */ + showIncomingCallNotification(data = data, asFullScreenIntent = currentSessionId == data.userId) } } } @@ -139,14 +154,14 @@ class CallNotificationManager @Inject constructor( @SuppressLint("MissingPermission") @VisibleForTesting - internal fun showIncomingCallNotification(data: CallNotificationData) { + internal fun showIncomingCallNotification(data: CallNotificationData, asFullScreenIntent: Boolean) { appLogger.i( "$TAG: showing incoming call notification for user ${data.userId.toLogString()}" + " and conversation ${data.conversationId.toLogString()}" ) val tag = NotificationConstants.getIncomingCallTag(data.userId.toString()) val id = NotificationConstants.getIncomingCallId(data.userId.toString(), data.conversationId.toString()) - val notification = builder.getIncomingCallNotification(data) + val notification = builder.getIncomingCallNotification(data, asFullScreenIntent) notificationManager.notify(tag, id, notification) } @@ -189,12 +204,11 @@ class CallNotificationBuilder @Inject constructor( ) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setFullScreenIntent(outgoingCallPendingIntent(context, conversationIdString), true) .setContentIntent(outgoingCallPendingIntent(context, conversationIdString)) .build() } - fun getIncomingCallNotification(data: CallNotificationData): Notification { + fun getIncomingCallNotification(data: CallNotificationData, asFullScreenIntent: Boolean): Notification { val conversationIdString = data.conversationId.toString() val userIdString = data.userId.toString() val title = getNotificationTitle(data) @@ -220,8 +234,14 @@ class CallNotificationBuilder @Inject constructor( ) .setVibrate(VIBRATE_PATTERN) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setFullScreenIntent(fullScreenIncomingCallPendingIntent(context, conversationIdString, userIdString), true) .setContentIntent(fullScreenIncomingCallPendingIntent(context, conversationIdString, userIdString)) + .let { + if (asFullScreenIntent) { + it.setFullScreenIntent(fullScreenIncomingCallPendingIntent(context, conversationIdString, userIdString), true) + } else { + it + } + } .build() // Added FLAG_INSISTENT so the ringing sound repeats itself until an action is done. @@ -255,7 +275,6 @@ class CallNotificationBuilder @Inject constructor( ) ) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setFullScreenIntent(openOngoingCallPendingIntent(context, conversationIdString), true) .setContentIntent(openOngoingCallPendingIntent(context, conversationIdString)) .build() } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 2cf4e38052b..a95911f1af0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -109,6 +109,7 @@ import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.CurrentScreenManager import com.wire.android.util.LocalSyncStateObserver +import com.wire.android.util.SwitchAccountObserver import com.wire.android.util.SyncStateObserver import com.wire.android.util.debug.FeatureVisibilityFlags import com.wire.android.util.debug.LocalFeatureVisibilityFlags @@ -135,6 +136,9 @@ class WireActivity : AppCompatActivity() { @Inject lateinit var lockCodeTimeManager: Lazy + @Inject + lateinit var switchAccountObserver: SwitchAccountObserver + private val viewModel: WireActivityViewModel by viewModels() private val featureFlagNotificationViewModel: FeatureFlagNotificationViewModel by viewModels() @@ -346,6 +350,19 @@ class WireActivity : AppCompatActivity() { navigator.navController.removeOnDestinationChangedListener(currentScreenManager) } } + + DisposableEffect(switchAccountObserver, navigator) { + NavigationSwitchAccountActions { + lifecycleScope.launch(Dispatchers.Main) { + navigator.navigate(it) + } + }.let { + switchAccountObserver.register(it) + onDispose { + switchAccountObserver.unregister(it) + } + } + } } @Composable diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 359291c8f48..d4ee4acfd2b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -80,10 +80,9 @@ import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSo import com.wire.kalium.util.DateTimeUtil.toIsoDateTimeString import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -131,24 +130,19 @@ class WireActivityViewModel @Inject constructor( private val _observeSyncFlowState: MutableStateFlow = MutableStateFlow(null) val observeSyncFlowState: StateFlow = _observeSyncFlowState - private val userIdDeferred: Deferred = viewModelScope.async(dispatchers.io()) { - currentSessionFlow.get().invoke() - .distinctUntilChanged() - .map { result -> - if (result is CurrentSessionResult.Success) { - if (result.accountInfo.isValid()) { - result.accountInfo.userId - } else { - null - } - } else { - null - } - } - .distinctUntilChanged() - .flowOn(dispatchers.io()) - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1).first() - } + private val observeCurrentAccountInfo: SharedFlow = currentSessionFlow.get().invoke() + .map { (it as? CurrentSessionResult.Success)?.accountInfo } + .distinctUntilChanged() + .flowOn(dispatchers.io()) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) + + private val observeCurrentValidUserId: SharedFlow = observeCurrentAccountInfo + .map { + if (it?.isValid() == true) it.userId else null + } + .distinctUntilChanged() + .flowOn(dispatchers.io()) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) init { observeSyncState() @@ -159,21 +153,10 @@ class WireActivityViewModel @Inject constructor( observeLogoutState() } - @Suppress("TooGenericExceptionCaught") - private fun shouldEnrollToE2ei() = viewModelScope.async(dispatchers.io()) { - try { - val userId = userIdDeferred.await() - if (userId != null) { - observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(userId) - .observeIfE2EIIsRequiredDuringLogin().first() ?: false - } else { - false - } - } catch (e: NullPointerException) { - appLogger.e("Error while observing E2EI state: $e") - false - } - } + private suspend fun shouldEnrollToE2ei(): Boolean = observeCurrentValidUserId.first()?.let { + observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(it) + .observeIfE2EIIsRequiredDuringLogin().first() ?: false + } ?: false private fun observeAppThemeState() { viewModelScope.launch(dispatchers.io()) { @@ -185,33 +168,28 @@ class WireActivityViewModel @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") private fun observeSyncState() { viewModelScope.launch(dispatchers.io()) { - try { - val userId = userIdDeferred.await() - if (userId != null) { - observeSyncStateUseCaseProviderFactory.create(userId).observeSyncState() - } else { - flowOf(null) - .distinctUntilChanged() - .collect { _observeSyncFlowState.emit(it) } + observeCurrentValidUserId + .flatMapLatest { userId -> + userId?.let { + observeSyncStateUseCaseProviderFactory.create(userId).observeSyncState() + } ?: flowOf(null) + } + .distinctUntilChanged() + .flowOn(dispatchers.io()) + .collect { + _observeSyncFlowState.emit(it) } - } catch (e: NullPointerException) { - appLogger.e("Error while observing sync state: $e") - } } } private fun observeLogoutState() { viewModelScope.launch(dispatchers.io()) { - currentSessionFlow.get().invoke() - .distinctUntilChanged() + observeCurrentAccountInfo .collect { - if (it is CurrentSessionResult.Success) { - if (it.accountInfo.isValid().not()) { - handleInvalidSession((it.accountInfo as AccountInfo.Invalid).logoutReason) - } + if (it is AccountInfo.Invalid) { + handleInvalidSession(it.logoutReason) } } } @@ -244,43 +222,29 @@ class WireActivityViewModel @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") private fun observeScreenshotCensoringConfigState() { viewModelScope.launch(dispatchers.io()) { - try { - val userId = userIdDeferred.await() - if (userId != null) { - observeScreenshotCensoringConfigUseCaseProviderFactory.create(userId) - .observeScreenshotCensoringConfig().collect { result -> - globalAppState = globalAppState.copy( - screenshotCensoringEnabled = result is ObserveScreenshotCensoringConfigResult.Enabled - ) - } - } else { - globalAppState = globalAppState.copy( - screenshotCensoringEnabled = false - ) + observeCurrentValidUserId + .flatMapLatest { currentValidUserId -> + currentValidUserId?.let { + observeScreenshotCensoringConfigUseCaseProviderFactory.create(it) + .observeScreenshotCensoringConfig() + .map { result -> + result is ObserveScreenshotCensoringConfigResult.Enabled + } + } ?: flowOf(false) + } + .collect { + globalAppState = globalAppState.copy(screenshotCensoringEnabled = it) } - } catch (exception: NullPointerException) { - globalAppState = globalAppState.copy( - screenshotCensoringEnabled = false - ) - } } } - suspend fun initialAppState(): InitialAppState { - val shouldMigrate = viewModelScope.async(dispatchers.io()) { - shouldMigrate() - } - val shouldLogin = viewModelScope.async(dispatchers.io()) { - shouldLogIn() - } - val shouldEnrollToE2ei = shouldEnrollToE2ei() - return when { - shouldMigrate.await() -> InitialAppState.NOT_MIGRATED - shouldLogin.await() -> InitialAppState.NOT_LOGGED_IN - shouldEnrollToE2ei.await() -> InitialAppState.ENROLL_E2EI + suspend fun initialAppState(): InitialAppState = withContext(dispatchers.io()) { + when { + shouldMigrate() -> InitialAppState.NOT_MIGRATED + shouldLogIn() -> InitialAppState.NOT_LOGGED_IN + shouldEnrollToE2ei() -> InitialAppState.ENROLL_E2EI else -> InitialAppState.LOGGED_IN } } @@ -517,17 +481,7 @@ class WireActivityViewModel @Inject constructor( globalAppState = globalAppState.copy(conversationJoinedDialog = null) } - private suspend fun shouldLogIn(): Boolean = !hasValidCurrentSession() - - private suspend fun hasValidCurrentSession(): Boolean = - // TODO: the usage of currentSessionFlow is a temporary solution, it should be replaced with a proper solution - currentSessionFlow.get().invoke().first().let { - when (it) { - is CurrentSessionResult.Failure.Generic -> false - CurrentSessionResult.Failure.SessionNotFound -> false - is CurrentSessionResult.Success -> true - } - } + private suspend fun shouldLogIn(): Boolean = observeCurrentValidUserId.first() == null private suspend fun shouldMigrate(): Boolean = migrationManager.get().shouldMigrate() diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt index 0ef6ae777a9..5c47a9ea4bd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt @@ -22,12 +22,20 @@ import android.os.Build import android.view.WindowManager import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import com.wire.android.util.SwitchAccountObserver import androidx.lifecycle.lifecycleScope import com.wire.android.ui.AppLockActivity import com.wire.kalium.logic.data.id.QualifiedIdMapperImpl +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import javax.inject.Inject +@AndroidEntryPoint abstract class CallActivity : AppCompatActivity() { + + @Inject + lateinit var switchAccountObserver: SwitchAccountObserver + companion object { const val EXTRA_CONVERSATION_ID = "conversation_id" const val EXTRA_USER_ID = "user_id" @@ -40,7 +48,7 @@ abstract class CallActivity : AppCompatActivity() { fun switchAccountIfNeeded(userId: String?) { userId?.let { qualifiedIdMapper.fromStringToQualifiedID(it).run { - callActivityViewModel.switchAccountIfNeeded(this) + callActivityViewModel.switchAccountIfNeeded(userId = this, actions = switchAccountObserver) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt index b504798e1e9..0dd6344b588 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider import com.wire.android.feature.AccountSwitchUseCase +import com.wire.android.feature.SwitchAccountActions import com.wire.android.feature.SwitchAccountParam import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.user.UserId @@ -59,7 +60,7 @@ class CallActivityViewModel @Inject constructor( } } - fun switchAccountIfNeeded(userId: UserId) { + fun switchAccountIfNeeded(userId: UserId, actions: SwitchAccountActions) { viewModelScope.launch(Dispatchers.IO) { val shouldSwitchAccount = when (val result = currentSession()) { is CurrentSessionResult.Failure.Generic -> true @@ -67,7 +68,9 @@ class CallActivityViewModel @Inject constructor( is CurrentSessionResult.Success -> result.accountInfo.userId != userId } if (shouldSwitchAccount) { - accountSwitch(SwitchAccountParam.SwitchToAccount(userId)) + accountSwitch(SwitchAccountParam.SwitchToAccount(userId)).also { + it.callAction(actions) + } } } } diff --git a/app/src/main/kotlin/com/wire/android/util/SwitchAccountObserver.kt b/app/src/main/kotlin/com/wire/android/util/SwitchAccountObserver.kt new file mode 100644 index 00000000000..ad75b3a7eb4 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/SwitchAccountObserver.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * 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 + * 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. + * + * 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.util + +import com.wire.android.feature.SwitchAccountActions +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SwitchAccountObserver @Inject constructor() : SwitchAccountActions { + private val lock = Object() + private val items = mutableListOf() + + fun register(actions: SwitchAccountActions) { + synchronized(lock) { + items.add(actions) + } + } + + fun unregister(actions: SwitchAccountActions) { + synchronized(lock) { + items.remove(actions) + } + } + + override fun switchedToAnotherAccount() { + synchronized(lock) { + items.forEach { + it.switchedToAnotherAccount() + } + } + } + + override fun noOtherAccountToSwitch() { + synchronized(lock) { + items.forEach { + it.noOtherAccountToSwitch() + } + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt index fc105378ac8..0d65af787d0 100644 --- a/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt @@ -23,13 +23,17 @@ import android.service.notification.StatusBarNotification import androidx.core.app.NotificationManagerCompat import com.wire.android.config.TestDispatcherProvider import com.wire.android.notification.CallNotificationManager.Companion.DEBOUNCE_TIME +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.session.CurrentSessionResult import io.mockk.MockKAnnotations import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk @@ -326,6 +330,42 @@ class CallNotificationManagerTest { verify(exactly = 1) { arrangement.notificationManager.cancel(tag, id) } } + @Test + fun `given incoming call for current session, when handling incoming call, then show it as full screen intent`() = + runTest(dispatcherProvider.main()) { + // given + val currentSession = AccountInfo.Valid(UserId("currentUserId", "domain")) + val userName = "user name" + val (arrangement, callNotificationManager) = Arrangement() + .withCurrentSession(currentSession) + .arrange() + // when + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), currentSession.userId, userName) + advanceUntilIdle() + // then + verify(exactly = 1) { + arrangement.callNotificationBuilder.getIncomingCallNotification(data = any(), asFullScreenIntent = eq(true)) + } + } + + @Test + fun `given incoming call for another session, when handling incoming call, then do not show it as full screen intent`() = + runTest(dispatcherProvider.main()) { + // given + val currentSession = AccountInfo.Valid(UserId("currentUserId", "domain")) + val userName = "user name" + val (arrangement, callNotificationManager) = Arrangement() + .withCurrentSession(currentSession) + .arrange() + // when + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) + advanceUntilIdle() + // then + verify(exactly = 1) { + arrangement.callNotificationBuilder.getIncomingCallNotification(data = any(), asFullScreenIntent = eq(false)) + } + } + private inner class Arrangement { @MockK @@ -337,11 +377,16 @@ class CallNotificationManagerTest { @MockK lateinit var callNotificationBuilder: CallNotificationBuilder + @MockK + lateinit var coreLogic: CoreLogic + init { MockKAnnotations.init(this, relaxUnitFun = true) mockkStatic(NotificationManagerCompat::from) every { NotificationManagerCompat.from(any()) } returns notificationManager withActiveNotifications(emptyList()) + every { callNotificationBuilder.getIncomingCallNotification(any(), any()) } returns mockk() + withCurrentSession(AccountInfo.Valid(UserId("userId", "domain"))) } fun clearRecordedCallsForNotificationManager() { @@ -356,14 +401,18 @@ class CallNotificationManagerTest { } fun withIncomingNotificationForUserAndCall(notification: Notification, forCallNotificationData: CallNotificationData) = apply { - every { callNotificationBuilder.getIncomingCallNotification(eq(forCallNotificationData)) } returns notification + every { callNotificationBuilder.getIncomingCallNotification(eq(forCallNotificationData), any()) } returns notification } fun withActiveNotifications(list: List) = apply { every { notificationManager.activeNotifications } returns list } - fun arrange() = this to CallNotificationManager(context, dispatcherProvider, callNotificationBuilder) + fun withCurrentSession(accountInfo: AccountInfo) = apply { + coEvery { coreLogic.getGlobalScope().session.currentSession() } returns CurrentSessionResult.Success(accountInfo) + } + + fun arrange() = this to CallNotificationManager(context, dispatcherProvider, callNotificationBuilder, coreLogic) } companion object { diff --git a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt index 6f18b93c1ad..1c7345672a2 100644 --- a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt @@ -20,6 +20,7 @@ package com.wire.android.ui import com.wire.android.config.TestDispatcherProvider import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider import com.wire.android.feature.AccountSwitchUseCase +import com.wire.android.feature.SwitchAccountActions import com.wire.android.feature.SwitchAccountResult import com.wire.android.ui.calling.CallActivityViewModel import com.wire.kalium.logic.data.auth.AccountInfo @@ -87,7 +88,7 @@ class CallActivityViewModelTest { .withAccountSwitch(SwitchAccountResult.Failure) .arrange() - viewModel.switchAccountIfNeeded(userId) + viewModel.switchAccountIfNeeded(userId, arrangement.switchAccountActions) advanceUntilIdle() coVerify(exactly = 1) { arrangement.accountSwitch(any()) } @@ -101,7 +102,7 @@ class CallActivityViewModelTest { .withAccountSwitch(SwitchAccountResult.SwitchedToAnotherAccount) .arrange() - viewModel.switchAccountIfNeeded(UserId("anotherUserId", "domain")) + viewModel.switchAccountIfNeeded(UserId("anotherUserId", "domain"), arrangement.switchAccountActions) advanceUntilIdle() coVerify(exactly = 1) { arrangement.accountSwitch(any()) } @@ -115,11 +116,59 @@ class CallActivityViewModelTest { .withAccountSwitch(SwitchAccountResult.SwitchedToAnotherAccount) .arrange() - viewModel.switchAccountIfNeeded(userId) + viewModel.switchAccountIfNeeded(userId, arrangement.switchAccountActions) coVerify(inverse = true) { arrangement.accountSwitch(any()) } } + private fun testCallingSwitchAccountActions( + switchAccountResult: SwitchAccountResult, + switchedToAnotherAccountCalled: Boolean = false, + noOtherAccountToSwitchCalled: Boolean = false, + ) = runTest { + val (arrangement, viewModel) = Arrangement() + .withCurrentSessionReturning(CurrentSessionResult.Success(AccountInfo.Valid(UserId("user", "domain")))) + .withAccountSwitch(switchAccountResult) + .arrange() + + viewModel.switchAccountIfNeeded(UserId("anotherUser", "domain"), arrangement.switchAccountActions) + + coVerify(exactly = if (switchedToAnotherAccountCalled) 1 else 0) { + arrangement.switchAccountActions.switchedToAnotherAccount() + } + coVerify(exactly = if (noOtherAccountToSwitchCalled) 1 else 0) { + arrangement.switchAccountActions.noOtherAccountToSwitch() + } + } + + @Test + fun `given no other account to switch, when switching, then call proper action`() = testCallingSwitchAccountActions( + switchAccountResult = SwitchAccountResult.NoOtherAccountToSwitch, + switchedToAnotherAccountCalled = false, + noOtherAccountToSwitchCalled = true, + ) + + @Test + fun `given account switched, when switching, then call proper action`() = testCallingSwitchAccountActions( + switchAccountResult = SwitchAccountResult.SwitchedToAnotherAccount, + switchedToAnotherAccountCalled = true, + noOtherAccountToSwitchCalled = false, + ) + + @Test + fun `given invalid account, when switching, then do not call any action`() = testCallingSwitchAccountActions( + switchAccountResult = SwitchAccountResult.GivenAccountIsInvalid, + switchedToAnotherAccountCalled = false, + noOtherAccountToSwitchCalled = false, + ) + + @Test + fun `given failure, when switching, then do not call any action`() = testCallingSwitchAccountActions( + switchAccountResult = SwitchAccountResult.Failure, + switchedToAnotherAccountCalled = false, + noOtherAccountToSwitchCalled = false, + ) + private class Arrangement { @MockK @@ -135,6 +184,9 @@ class CallActivityViewModelTest { @MockK private lateinit var observeScreenshotCensoringConfig: ObserveScreenshotCensoringConfigUseCase + @MockK + lateinit var switchAccountActions: SwitchAccountActions + init { MockKAnnotations.init(this, relaxUnitFun = true) diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 4b492a219b3..6169a00d52f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -23,6 +23,7 @@ package com.wire.android.ui import android.content.Intent import androidx.work.WorkManager import androidx.work.impl.OperationImpl +import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri @@ -56,6 +57,7 @@ import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.sync.SyncState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.appVersioning.ObserveIfAppUpdateRequiredUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase @@ -88,6 +90,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals @@ -635,6 +638,50 @@ class WireActivityViewModelTest { assertEquals(false, viewModel.globalAppState.screenshotCensoringEnabled) } + @Test + fun `given session changes, when observing screenshot censoring, then update screenshot censoring state`() = runTest { + val firstSession = AccountInfo.Valid(UserId("user1", "domain1")) + val secondSession = AccountInfo.Valid(UserId("user2", "domain2")) + val firstSessionScreenshotCensoringConfig = ObserveScreenshotCensoringConfigResult.Disabled + val secondSessionScreenshotCensoringConfig = ObserveScreenshotCensoringConfigResult.Enabled.ChosenByUser + val currentSessionFlow = MutableStateFlow(firstSession) + val (_, viewModel) = Arrangement() + .withCurrentSessionFlow(currentSessionFlow.map { CurrentSessionResult.Success(it) }) + .withScreenshotCensoringConfigForUser(firstSession.userId, firstSessionScreenshotCensoringConfig) + .withScreenshotCensoringConfigForUser(secondSession.userId, secondSessionScreenshotCensoringConfig) + .arrange() + advanceUntilIdle() + assertEquals(false, viewModel.globalAppState.screenshotCensoringEnabled) + + currentSessionFlow.emit(secondSession) + advanceUntilIdle() + assertEquals(true, viewModel.globalAppState.screenshotCensoringEnabled) + } + + @Test + fun `given session changes, when observing sync state, then update sync state`() = runTest { + val firstSession = AccountInfo.Valid(UserId("user1", "domain1")) + val secondSession = AccountInfo.Valid(UserId("user2", "domain2")) + val firstSessionSyncState = SyncState.Live + val secondSessionSyncState = SyncState.SlowSync + val currentSessionFlow = MutableStateFlow(firstSession) + val (_, viewModel) = Arrangement() + .withCurrentSessionFlow(currentSessionFlow.map { CurrentSessionResult.Success(it) }) + .withSyncStateForUser(firstSession.userId, firstSessionSyncState) + .withSyncStateForUser(secondSession.userId, secondSessionSyncState) + .arrange() + advanceUntilIdle() + viewModel.observeSyncFlowState.test { + assertEquals(firstSessionSyncState, awaitItem()) + + currentSessionFlow.emit(secondSession) + advanceUntilIdle() + assertEquals(secondSessionSyncState, awaitItem()) + + expectNoEvents() + } + } + @Test fun `given app theme change, when observing it, then update state with theme option`() = runTest { val (_, viewModel) = Arrangement() @@ -806,6 +853,10 @@ class WireActivityViewModelTest { return this } + fun withCurrentSessionFlow(result: Flow): Arrangement = apply { + coEvery { currentSessionFlow() } returns result + } + fun withDeepLinkResult(result: DeepLinkResult, isSharingIntent: Boolean = false): Arrangement { coEvery { deepLinkProcessor(any(), isSharingIntent) } returns result return this @@ -877,6 +928,20 @@ class WireActivityViewModelTest { coEvery { observeScreenshotCensoringConfigUseCase() } returns flowOf(result) } + suspend fun withScreenshotCensoringConfigForUser(id: UserId, result: ObserveScreenshotCensoringConfigResult) = apply { + val useCase = mockk() + coEvery { + observeScreenshotCensoringConfigUseCaseProviderFactory.create(id).observeScreenshotCensoringConfig + } returns useCase + coEvery { useCase() } returns flowOf(result) + } + + fun withSyncStateForUser(id: UserId, result: SyncState) = apply { + val useCase = mockk() + coEvery { observeSyncStateUseCaseProviderFactory.create(id).observeSyncState } returns useCase + coEvery { useCase() } returns flowOf(result) + } + suspend fun withThemeOption(themeOption: ThemeOption) = apply { coEvery { globalDataStore.selectedThemeOptionFlow() } returns flowOf(themeOption) } From e6d8091c3672a4e4b9f7121d736b3a228d9dd776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Tue, 10 Dec 2024 13:38:13 +0100 Subject: [PATCH 2/2] feat: filter folders [WPB-14442] (#3714) --- .../di/accountScoped/ConversationModule.kt | 5 + .../wire/android/navigation/FolderNavArgs.kt | 27 ++++ .../android/navigation/HomeDestination.kt | 70 ++++++--- .../bottomsheet/RichMenuBottomSheetItem.kt | 36 ++++- .../com/wire/android/ui/home/HomeScreen.kt | 57 +++++-- .../wire/android/ui/home/HomeStateHolder.kt | 29 ++-- .../com/wire/android/ui/home/HomeTopBar.kt | 4 +- .../folder/ConversationFoldersVM.kt | 68 +++++++++ .../GetConversationsFromSearchUseCase.kt | 38 +++-- .../ConversationListViewModel.kt | 13 +- .../ConversationsScreenContent.kt | 6 +- .../all/AllConversationsScreen.kt | 23 ++- .../all/ConversationsEmptyContent.kt | 24 +-- .../filter/ConversationFilterSheetContent.kt | 89 ++++++----- .../filter/ConversationFilterSheetState.kt | 56 +++++++ .../filter/ConversationFiltersSheetContent.kt | 126 ++++++++++++++++ .../filter/ConversationFoldersSheetContent.kt | 142 ++++++++++++++++++ .../model/ConversationsSource.kt | 14 +- .../wire/android/ui/home/drawer/HomeDrawer.kt | 2 +- .../main/res/drawable/ic_folders_outline.xml | 10 ++ app/src/main/res/values/strings.xml | 5 + .../GetConversationsFromSearchUseCaseTest.kt | 2 +- .../ConversationListViewModelTest.kt | 2 +- .../bottomsheet/ModalSheetHeaderItem.kt | 16 +- kalium | 2 +- 25 files changed, 724 insertions(+), 142 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersVM.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetState.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFiltersSheetContent.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt create mode 100644 app/src/main/res/drawable/ic_folders_outline.xml diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index f726d098dc4..244cd31a5cf 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -337,4 +337,9 @@ class ConversationModule { @Provides fun provideRemoveConversationFromFavoritesUseCase(conversationScope: ConversationScope) = conversationScope.removeConversationFromFavorites + + @ViewModelScoped + @Provides + fun provideObserveUserFoldersUseCase(conversationScope: ConversationScope) = + conversationScope.observeUserFolders } diff --git a/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt b/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt new file mode 100644 index 00000000000..ec7977926ae --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2024 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.navigation + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FolderNavArgs( + val folderId: String, + val folderName: String +) : Parcelable diff --git a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt index 3db5a7e8a03..42aed03d34a 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -19,30 +19,38 @@ package com.wire.android.navigation import androidx.annotation.DrawableRes -import androidx.annotation.StringRes +import androidx.navigation.NavBackStackEntry import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R import com.wire.android.ui.destinations.AllConversationsScreenDestination import com.wire.android.ui.destinations.ArchiveScreenDestination import com.wire.android.ui.destinations.FavoritesConversationsScreenDestination +import com.wire.android.ui.destinations.FolderConversationsScreenDestination import com.wire.android.ui.destinations.GroupConversationsScreenDestination import com.wire.android.ui.destinations.OneOnOneConversationsScreenDestination import com.wire.android.ui.destinations.SettingsScreenDestination import com.wire.android.ui.destinations.VaultScreenDestination import com.wire.android.ui.destinations.WhatsNewScreenDestination +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ConversationFilter +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf @Suppress("LongParameterList") sealed class HomeDestination( - @StringRes val title: Int, + val title: UIText, @DrawableRes val icon: Int, val isSearchable: Boolean = false, val withNewConversationFab: Boolean = false, val withUserAvatar: Boolean = true, val direction: Direction ) { + + internal fun NavBackStackEntry.baseRouteMatches(): Boolean = direction.route.getBaseRoute() == destination.route?.getBaseRoute() + open fun entryMatches(entry: NavBackStackEntry): Boolean = entry.baseRouteMatches() + data object Conversations : HomeDestination( - title = R.string.conversations_screen_title, + title = UIText.StringResource(R.string.conversations_screen_title), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, @@ -50,15 +58,28 @@ sealed class HomeDestination( ) data object Favorites : HomeDestination( - title = R.string.label_filter_favorites, + title = UIText.StringResource(R.string.label_filter_favorites), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, direction = FavoritesConversationsScreenDestination ) + data class Folder( + val folderNavArgs: FolderNavArgs + ) : HomeDestination( + title = UIText.DynamicString(folderNavArgs.folderName), + icon = R.drawable.ic_conversation, + isSearchable = true, + withNewConversationFab = true, + direction = FolderConversationsScreenDestination(folderNavArgs) + ) { + override fun entryMatches(entry: NavBackStackEntry): Boolean = + entry.baseRouteMatches() && FolderConversationsScreenDestination.argsFrom(entry).folderId == folderNavArgs.folderId + } + data object Group : HomeDestination( - title = R.string.label_filter_group, + title = UIText.StringResource(R.string.label_filter_group), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, @@ -66,7 +87,7 @@ sealed class HomeDestination( ) data object OneOnOne : HomeDestination( - title = R.string.label_filter_one_on_one, + title = UIText.StringResource(R.string.label_filter_one_on_one), icon = R.drawable.ic_conversation, isSearchable = true, withNewConversationFab = true, @@ -74,33 +95,33 @@ sealed class HomeDestination( ) data object Settings : HomeDestination( - title = R.string.settings_screen_title, + title = UIText.StringResource(R.string.settings_screen_title), icon = R.drawable.ic_settings, withUserAvatar = false, direction = SettingsScreenDestination ) data object Vault : HomeDestination( - title = R.string.vault_screen_title, + title = UIText.StringResource(R.string.vault_screen_title), icon = R.drawable.ic_vault, direction = VaultScreenDestination ) data object Archive : HomeDestination( - title = R.string.archive_screen_title, + title = UIText.StringResource(R.string.archive_screen_title), icon = R.drawable.ic_archive, isSearchable = true, direction = ArchiveScreenDestination ) data object Support : HomeDestination( - title = R.string.support_screen_title, + title = UIText.StringResource(R.string.support_screen_title), icon = R.drawable.ic_support, direction = SupportScreenDestination ) data object WhatsNew : HomeDestination( - title = R.string.whats_new_screen_title, + title = UIText.StringResource(R.string.whats_new_screen_title), icon = R.drawable.ic_star, direction = WhatsNewScreenDestination ) @@ -109,33 +130,32 @@ sealed class HomeDestination( companion object { private const val ITEM_NAME_PREFIX = "HomeNavigationItem." - fun fromRoute(fullRoute: String): HomeDestination? = - values().find { it.direction.route.getBaseRoute() == fullRoute.getBaseRoute() } - - fun values(): Array = - arrayOf(Conversations, Favorites, Group, OneOnOne, Settings, Vault, Archive, Support, WhatsNew) + fun values(): PersistentList = + persistentListOf(Conversations, Favorites, Group, OneOnOne, Settings, Vault, Archive, Support, WhatsNew) } } fun HomeDestination.currentFilter(): ConversationFilter { return when (this) { - HomeDestination.Conversations -> ConversationFilter.ALL - HomeDestination.Favorites -> ConversationFilter.FAVORITES - HomeDestination.Group -> ConversationFilter.GROUPS - HomeDestination.OneOnOne -> ConversationFilter.ONE_ON_ONE + HomeDestination.Conversations -> ConversationFilter.All + HomeDestination.Favorites -> ConversationFilter.Favorites + HomeDestination.Group -> ConversationFilter.Groups + HomeDestination.OneOnOne -> ConversationFilter.OneOnOne + is HomeDestination.Folder -> ConversationFilter.Folder(folderName = folderNavArgs.folderName, folderId = folderNavArgs.folderId) HomeDestination.Archive, HomeDestination.Settings, HomeDestination.Support, HomeDestination.Vault, - HomeDestination.WhatsNew -> ConversationFilter.ALL + HomeDestination.WhatsNew -> ConversationFilter.All } } fun ConversationFilter.toDestination(): HomeDestination { return when (this) { - ConversationFilter.ALL -> HomeDestination.Conversations - ConversationFilter.FAVORITES -> HomeDestination.Favorites - ConversationFilter.GROUPS -> HomeDestination.Group - ConversationFilter.ONE_ON_ONE -> HomeDestination.OneOnOne + ConversationFilter.All -> HomeDestination.Conversations + ConversationFilter.Favorites -> HomeDestination.Favorites + ConversationFilter.Groups -> HomeDestination.Group + ConversationFilter.OneOnOne -> HomeDestination.OneOnOne + is ConversationFilter.Folder -> HomeDestination.Folder(FolderNavArgs(folderId, folderName)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt index 0c7697fa20b..031ae12e567 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/RichMenuBottomSheetItem.kt @@ -41,6 +41,7 @@ import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.ui.common.WireCheckIcon import com.wire.android.ui.common.clickable +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.DEFAULT_WEIGHT import com.wire.android.ui.theme.wireColorScheme @@ -55,6 +56,7 @@ fun SelectableMenuBottomSheetItem( titleStyleUnselected: TextStyle = MaterialTheme.wireTypography.body02, titleStyleSelected: TextStyle = MaterialTheme.wireTypography.body02, subLine: String? = null, + description: String? = null, icon: @Composable () -> Unit = { }, onItemClick: Clickable = Clickable(enabled = false) {}, state: RichMenuItemState = RichMenuItemState.DEFAULT @@ -77,12 +79,30 @@ fun SelectableMenuBottomSheetItem( modifier = Modifier .weight(DEFAULT_WEIGHT), ) { - MenuItemHeading( - title = title, color = titleColor, - titleStyleUnselected = titleStyleUnselected, - titleStyleSelected = titleStyleSelected, - state = state - ) + Row { + MenuItemHeading( + title = title, + color = titleColor, + titleStyleUnselected = titleStyleUnselected, + titleStyleSelected = titleStyleSelected, + state = state, + modifier = if (description != null) { + Modifier + } else { + Modifier.weight(1F) + } + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.wireTypography.body01, + color = colorsScheme().secondaryText, + modifier = Modifier + .weight(1f) + .padding(start = dimensions().spacing16x) + ) + } + } if (subLine != null) { MenuItemSubLine( subLine = subLine, @@ -106,7 +126,7 @@ fun SelectableMenuBottomSheetItem( @Composable fun MenuItemHeading( title: String, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier.fillMaxWidth(), titleStyleUnselected: TextStyle = MaterialTheme.wireTypography.body02, titleStyleSelected: TextStyle = MaterialTheme.wireTypography.body02, state: RichMenuItemState = RichMenuItemState.DEFAULT, @@ -116,7 +136,7 @@ fun MenuItemHeading( style = if (isSelectedItem(state)) titleStyleSelected else titleStyleUnselected, color = if (isSelectedItem(state)) MaterialTheme.wireColorScheme.primary else color ?: MaterialTheme.wireColorScheme.onBackground, text = title, - modifier = modifier.fillMaxWidth() + modifier = modifier ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index d820902563c..32907047c6e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -62,11 +63,12 @@ import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.hiltViewModelScoped +import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination -import com.wire.android.navigation.currentFilter import com.wire.android.navigation.handleNavigation import com.wire.android.navigation.toDestination import com.wire.android.ui.NavGraphs @@ -87,12 +89,19 @@ import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.details.GroupConversationActionType import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs +import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs +import com.wire.android.ui.home.conversations.folder.ConversationFoldersVM +import com.wire.android.ui.home.conversations.folder.ConversationFoldersVMImpl import com.wire.android.ui.home.conversationslist.filter.ConversationFilterSheetContent +import com.wire.android.ui.home.conversationslist.filter.ConversationFilterSheetData +import com.wire.android.ui.home.conversationslist.filter.rememberFilterSheetState import com.wire.android.ui.home.drawer.HomeDrawer import com.wire.android.ui.home.drawer.HomeDrawerState import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.util.permission.rememberShowNotificationsPermissionFlow -import com.wire.kalium.logic.data.conversation.ConversationFilter +import com.wire.kalium.logic.data.conversation.ConversationFolder +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch @RootNavGraph @@ -105,10 +114,21 @@ fun HomeScreen( homeViewModel: HomeViewModel = hiltViewModel(), appSyncViewModel: AppSyncViewModel = hiltViewModel(), homeDrawerViewModel: HomeDrawerViewModel = hiltViewModel(), - analyticsUsageViewModel: AnalyticsUsageViewModel = hiltViewModel() + analyticsUsageViewModel: AnalyticsUsageViewModel = hiltViewModel(), + foldersViewModel: ConversationFoldersVM = + hiltViewModelScoped( + ConversationFoldersStateArgs + ) ) { homeViewModel.checkRequirements { it.navigate(navigator::navigate) } - val homeScreenState = rememberHomeScreenState(navigator) + val homeDestinations = remember(foldersViewModel.state().folders) { + HomeDestination.values() + .plus( + foldersViewModel.state().folders.map { HomeDestination.Folder(FolderNavArgs(it.id, it.name)) } + ) + } + + val homeScreenState = rememberHomeScreenState(navigator, homeDestinations = homeDestinations) val notificationsPermissionDeniedDialogState = rememberVisibilityState() val showNotificationsPermissionDeniedDialog = { notificationsPermissionDeniedDialogState.show( @@ -169,7 +189,8 @@ fun HomeScreen( onSelfUserClick = { homeViewModel.sendOpenProfileEvent() navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) - } + }, + folders = foldersViewModel.state().folders ) BackHandler(homeScreenState.drawerState.isOpen) { @@ -240,9 +261,10 @@ fun HomeContent( onNewConversationClick: () -> Unit, onSelfUserClick: () -> Unit, modifier: Modifier = Modifier, + folders: PersistentList = persistentListOf() ) { val context = LocalContext.current - val filterSheetState = rememberWireModalSheetState() + val filterSheetState = rememberWireModalSheetState() with(homeStateHolder) { fun openHomeDestination(item: HomeDestination) { @@ -252,6 +274,7 @@ fun HomeContent( navController.navigate(direction.route) { navController.graph.startDestinationRoute?.let { route -> popUpTo(route) { + inclusive = true saveState = true } } @@ -302,7 +325,14 @@ fun HomeContent( shouldShowCreateTeamUnreadIndicator = homeState.shouldShowCreateTeamUnreadIndicator, onHamburgerMenuClick = ::openDrawer, onNavigateToSelfUserProfile = onSelfUserClick, - onOpenConversationFilter = { filterSheetState.show(it) } + onOpenConversationFilter = { + filterSheetState.show( + ConversationFilterSheetData( + currentFilter = it, + folders = folders + ) + ) + } ) } }, @@ -317,7 +347,7 @@ fun HomeContent( } }, collapsingEnabled = !searchBarState.isSearchActive, - contentLazyListState = homeStateHolder.lazyListStateFor(currentNavigationItem), + contentLazyListState = homeStateHolder.nullAbleLazyListStateFor(currentNavigationItem), content = { /** * This "if" is a workaround, otherwise it can crash because of the SubcomposeLayout's nature. @@ -372,13 +402,18 @@ fun HomeContent( ) WireModalSheetLayout( sheetState = filterSheetState, - sheetContent = { + sheetContent = { sheetData -> + val sheetContentState = rememberFilterSheetState(sheetData) ConversationFilterSheetContent( - currentFilter = currentNavigationItem.currentFilter(), onChangeFilter = { filter -> filterSheetState.hide() openHomeDestination(filter.toDestination()) - } + }, + onChangeFolder = { + filterSheetState.hide() + openHomeDestination(it.toDestination()) + }, + filterSheetState = sheetContentState ) } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt index 034c10e9250..f6e802ce309 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt @@ -32,7 +32,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.wire.android.navigation.HomeDestination +import com.wire.android.navigation.HomeDestination.Conversations import com.wire.android.navigation.Navigator +import com.wire.android.navigation.getBaseRoute import com.wire.android.navigation.rememberTrackingAnimatedNavController import com.wire.android.ui.common.topappbar.search.SearchBarState import com.wire.android.ui.common.topappbar.search.rememberSearchbarState @@ -51,8 +53,14 @@ class HomeStateHolder( ) { val currentNavigationItem get() = currentNavigationItemState.value - fun lazyListStateFor(destination: HomeDestination): LazyListState = - lazyListStates[destination] ?: error("No LazyListState found for $destination") + + fun lazyListStateFor(destination: HomeDestination): LazyListState { + return lazyListStates[destination] ?: error("No LazyListState found for $destination") + } + + fun nullAbleLazyListStateFor(destination: HomeDestination): LazyListState? { + return lazyListStates[destination] + } fun closeDrawer() { coroutineScope.launch { @@ -70,22 +78,25 @@ class HomeStateHolder( @Composable fun rememberHomeScreenState( navigator: Navigator, + homeDestinations: List, coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberTrackingAnimatedNavController { - HomeDestination.fromRoute(it)?.itemName + navController: NavHostController = rememberTrackingAnimatedNavController { route -> + homeDestinations.find { it.direction.route.getBaseRoute() == route }?.itemName }, - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed) ): HomeStateHolder { + val searchBarState = rememberSearchbarState() val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentNavigationItemState = remember { + + val currentNavigationItemState = remember(homeDestinations) { derivedStateOf { - navBackStackEntry?.destination?.route?.let { HomeDestination.fromRoute(it) } ?: HomeDestination.Conversations + navBackStackEntry?.let { entry -> homeDestinations.find { it.entryMatches(entry) } } ?: Conversations } } - val lazyListStates = HomeDestination.values().associateWith { rememberLazyListState() } + val lazyListStates = homeDestinations.associateWith { rememberLazyListState() } - return remember { + return remember(homeDestinations) { HomeStateHolder( coroutineScope = coroutineScope, navController = navController, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt index 9f34130884c..28039c3ea12 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt @@ -52,7 +52,7 @@ fun HomeTopBar( onOpenConversationFilter: (filter: ConversationFilter) -> Unit ) { WireCenterAlignedTopAppBar( - title = stringResource(navigationItem.title), + title = navigationItem.title.asString(), onNavigationPressed = onHamburgerMenuClick, navigationIconType = NavigationIconType.Menu, actions = { @@ -60,7 +60,7 @@ fun HomeTopBar( WireTertiaryIconButton( iconResource = R.drawable.ic_filter, contentDescription = R.string.label_filter_conversations, - state = if (navigationItem.currentFilter() == ConversationFilter.ALL) { + state = if (navigationItem.currentFilter() == ConversationFilter.All) { WireButtonState.Default } else { WireButtonState.Selected diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersVM.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersVM.kt new file mode 100644 index 00000000000..b15fc52eef6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersVM.kt @@ -0,0 +1,68 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.folder + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.di.ScopedArgs +import com.wire.android.di.ViewModelScopedPreview +import com.wire.kalium.logic.data.conversation.ConversationFolder +import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import javax.inject.Inject + +@ViewModelScopedPreview +interface ConversationFoldersVM { + fun state(): ConversationFoldersState = ConversationFoldersState(persistentListOf()) +} + +@HiltViewModel +class ConversationFoldersVMImpl @Inject constructor( + private val observeUserFoldersUseCase: ObserveUserFoldersUseCase, +) : ConversationFoldersVM, ViewModel() { + + private var state by mutableStateOf(ConversationFoldersState(persistentListOf())) + + override fun state(): ConversationFoldersState = state + + init { + observeUserFolders() + } + + private fun observeUserFolders() = viewModelScope.launch { + observeUserFoldersUseCase() + .collect { folders -> + state = ConversationFoldersState(folders.toPersistentList()) + } + } +} + +data class ConversationFoldersState(val folders: PersistentList) + +@Serializable +object ConversationFoldersStateArgs : ScopedArgs { + override val key = "ConversationFoldersStateArgsKey" +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index a7d9584055b..63b0a150f9b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -27,6 +27,7 @@ import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.ConversationQueryConfig import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase @@ -53,7 +54,7 @@ class GetConversationsFromSearchUseCase @Inject constructor( fromArchive: Boolean = false, newActivitiesOnTop: Boolean = false, onlyInteractionEnabled: Boolean = false, - conversationFilter: ConversationFilter = ConversationFilter.ALL + conversationFilter: ConversationFilter = ConversationFilter.All ): Flow> { val pagingConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -62,9 +63,9 @@ class GetConversationsFromSearchUseCase @Inject constructor( enablePlaceholders = true, ) return when (conversationFilter) { - ConversationFilter.ALL, - ConversationFilter.GROUPS, - ConversationFilter.ONE_ON_ONE -> useCase( + ConversationFilter.All, + ConversationFilter.Groups, + ConversationFilter.OneOnOne -> useCase( queryConfig = ConversationQueryConfig( searchQuery = searchQuery, fromArchive = fromArchive, @@ -76,22 +77,18 @@ class GetConversationsFromSearchUseCase @Inject constructor( startingOffset = 0L, ) - ConversationFilter.FAVORITES -> { + ConversationFilter.Favorites -> { when (val result = getFavoriteFolderUseCase.invoke()) { GetFavoriteFolderUseCase.Result.Failure -> flowOf(emptyList()) is GetFavoriteFolderUseCase.Result.Success -> observeConversationsFromFromFolder(result.folder.id) } - .map { - PagingData.from( - it, - sourceLoadStates = LoadStates( - prepend = LoadState.NotLoading(true), - append = LoadState.NotLoading(true), - refresh = LoadState.NotLoading(true), - ) - ) - } + .map { staticPagingItems(it) } + } + + is ConversationFilter.Folder -> { + observeConversationsFromFromFolder(conversationFilter.folderId) + .map { staticPagingItems(it) } } } .map { pagingData -> @@ -105,6 +102,17 @@ class GetConversationsFromSearchUseCase @Inject constructor( }.flowOn(dispatchers.io()) } + private fun staticPagingItems(conversations: List): PagingData { + return PagingData.from( + conversations, + sourceLoadStates = LoadStates( + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + refresh = LoadState.NotLoading(true), + ) + ) + } + private companion object { const val PAGE_SIZE = 20 const val INITIAL_LOAD_SIZE = 60 diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 3ec4d460b94..466664122da 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -165,6 +165,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val containsNewActivitiesSection = when (conversationsSource) { ConversationsSource.MAIN, ConversationsSource.FAVORITES, + is ConversationsSource.FOLDER, ConversationsSource.GROUPS, ConversationsSource.ONE_ON_ONE -> true @@ -439,11 +440,12 @@ class ConversationListViewModelImpl @AssistedInject constructor( fun Conversation.LegalHoldStatus.showLegalHoldIndicator() = this == Conversation.LegalHoldStatus.ENABLED private fun ConversationsSource.toFilter(): ConversationFilter = when (this) { - ConversationsSource.MAIN -> ConversationFilter.ALL - ConversationsSource.ARCHIVE -> ConversationFilter.ALL - ConversationsSource.GROUPS -> ConversationFilter.GROUPS - ConversationsSource.FAVORITES -> ConversationFilter.FAVORITES - ConversationsSource.ONE_ON_ONE -> ConversationFilter.ONE_ON_ONE + ConversationsSource.MAIN -> ConversationFilter.All + ConversationsSource.ARCHIVE -> ConversationFilter.All + ConversationsSource.GROUPS -> ConversationFilter.Groups + ConversationsSource.FAVORITES -> ConversationFilter.Favorites + ConversationsSource.ONE_ON_ONE -> ConversationFilter.OneOnOne + is ConversationsSource.FOLDER -> ConversationFilter.Folder(folderId = folderId, folderName = folderName) } private fun ConversationItem.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus: LegalHoldStateForSelfUser) = @@ -473,6 +475,7 @@ private fun List.withFolders(source: ConversationsSource): Map ConversationsSource.FAVORITES, ConversationsSource.GROUPS, ConversationsSource.ONE_ON_ONE, + is ConversationsSource.FOLDER, ConversationsSource.MAIN -> { val unreadConversations = filter { when (it.mutedStatus) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 244fc837067..3a180efd9a7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -92,7 +92,7 @@ fun ConversationsScreenContent( conversationListViewModel: ConversationListViewModel = when { LocalInspectionMode.current -> ConversationListViewModelPreview() else -> hiltViewModel( - key = "list_${conversationsSource.name}", + key = "list_$conversationsSource", creationCallback = { factory -> factory.create(conversationsSource = conversationsSource) } @@ -100,7 +100,7 @@ fun ConversationsScreenContent( }, conversationCallListViewModel: ConversationCallListViewModel = when { LocalInspectionMode.current -> ConversationCallListViewModelPreview - else -> hiltViewModel(key = "call_${conversationsSource.name}") + else -> hiltViewModel(key = "call_$conversationsSource") }, changeConversationFavoriteStateViewModel: ChangeConversationFavoriteVM = hiltViewModelScoped( @@ -189,7 +189,7 @@ fun ConversationsScreenContent( when (val state = conversationListViewModel.conversationListState) { is ConversationListState.Paginated -> { val lazyPagingItems = state.conversations.collectAsLazyPagingItems() - var showLoading by remember { mutableStateOf(!initiallyLoaded) } + var showLoading by remember(conversationsSource) { mutableStateOf(!initiallyLoaded) } if (lazyPagingItems.loadState.refresh != LoadState.Loading && showLoading) { showLoading = false } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt index 3c7da0a4825..03318926dc3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.home.conversationslist.all import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable +import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeNavGraph import com.wire.android.navigation.WireDestination @@ -45,7 +46,7 @@ fun AllConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.MAIN, lazyListState = lazyListStateFor(HomeDestination.Conversations), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.ALL) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.All) } ) } } @@ -60,7 +61,21 @@ fun FavoritesConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.FAVORITES, lazyListState = lazyListStateFor(HomeDestination.Favorites), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.FAVORITES) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Favorites) } + ) + } +} + +@HomeNavGraph +@WireDestination(navArgsDelegate = FolderNavArgs::class) +@Composable +fun FolderConversationsScreen(homeStateHolder: HomeStateHolder, args: FolderNavArgs) { + with(homeStateHolder) { + ConversationsScreenContent( + navigator = navigator, + searchBarState = searchBarState, + conversationsSource = ConversationsSource.FOLDER(args.folderId, args.folderName), + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Folder(args.folderId, args.folderName)) } ) } } @@ -75,7 +90,7 @@ fun GroupConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.GROUPS, lazyListState = lazyListStateFor(HomeDestination.Group), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.GROUPS) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Groups) } ) } } @@ -90,7 +105,7 @@ fun OneOnOneConversationsScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.ONE_ON_ONE, lazyListState = lazyListStateFor(HomeDestination.OneOnOne), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.ONE_ON_ONE, domain = it) } + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.OneOnOne, domain = it) } ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt index da2f82fe5f7..c51e6961e41 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt @@ -45,7 +45,7 @@ import com.wire.kalium.logic.data.conversation.ConversationFilter @Composable fun ConversationsEmptyContent( modifier: Modifier = Modifier, - filter: ConversationFilter = ConversationFilter.ALL, + filter: ConversationFilter = ConversationFilter.All, domain: String = "wire.com" ) { val context = LocalContext.current @@ -58,7 +58,7 @@ fun ConversationsEmptyContent( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - if (filter == ConversationFilter.ALL) { + if (filter == ConversationFilter.All) { Text( modifier = Modifier.padding( bottom = dimensions().spacing24x, @@ -76,7 +76,7 @@ fun ConversationsEmptyContent( textAlign = TextAlign.Center, color = MaterialTheme.wireColorScheme.onSurface, ) - if (filter == ConversationFilter.FAVORITES) { + if (filter == ConversationFilter.Favorites) { val supportUrl = stringResource(id = R.string.url_how_to_add_favorites) Text( text = stringResource(R.string.favorites_empty_list_how_to_label), @@ -102,32 +102,34 @@ fun ConversationsEmptyContent( @Composable private fun ConversationFilter.emptyDescription(backendName: String): String = when (this) { - ConversationFilter.ALL -> stringResource(R.string.conversation_empty_list_description) - ConversationFilter.FAVORITES -> stringResource(R.string.favorites_empty_list_description) - ConversationFilter.GROUPS -> stringResource(R.string.group_empty_list_description) - ConversationFilter.ONE_ON_ONE -> stringResource(R.string.one_on_one_empty_list_description, backendName) + ConversationFilter.All -> stringResource(R.string.conversation_empty_list_description) + ConversationFilter.Favorites -> stringResource(R.string.favorites_empty_list_description) + ConversationFilter.Groups -> stringResource(R.string.group_empty_list_description) + ConversationFilter.OneOnOne -> stringResource(R.string.one_on_one_empty_list_description, backendName) + // currently not used, because empty folders are removed from filters + is ConversationFilter.Folder -> "" } @PreviewMultipleThemes @Composable fun PreviewAllConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.ALL) + ConversationsEmptyContent(filter = ConversationFilter.All) } @PreviewMultipleThemes @Composable fun PreviewFavoritesConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.FAVORITES) + ConversationsEmptyContent(filter = ConversationFilter.Favorites) } @PreviewMultipleThemes @Composable fun PreviewGroupConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.GROUPS) + ConversationsEmptyContent(filter = ConversationFilter.Groups) } @PreviewMultipleThemes @Composable fun PreviewOneOnOneConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.ONE_ON_ONE, domain = "wire.com") + ConversationsEmptyContent(filter = ConversationFilter.OneOnOne, domain = "wire.com") } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt index 4d7f58064cc..0dcb4da607f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt @@ -17,54 +17,65 @@ */ package com.wire.android.ui.home.conversationslist.filter -import androidx.compose.material3.MaterialTheme +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.remember import com.wire.android.R -import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem -import com.wire.android.ui.common.bottomsheet.MenuItemIcon -import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader -import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent -import com.wire.android.ui.common.dimensions -import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ConversationFilter @Composable fun ConversationFilterSheetContent( - currentFilter: ConversationFilter, - onChangeFilter: (ConversationFilter) -> Unit + filterSheetState: ConversationFilterSheetState, + onChangeFilter: (ConversationFilter) -> Unit, + onChangeFolder: (ConversationFilter.Folder) -> Unit, + isBottomSheetVisible: () -> Boolean = { true } ) { - WireMenuModalSheetContent( - header = MenuModalSheetHeader.Visible( - title = stringResource(R.string.label_filter_conversations), - customVerticalPadding = dimensions().spacing8x - ), - menuItems = buildList<@Composable () -> Unit> { - ConversationFilter.entries.forEach { filter -> - add { - MenuBottomSheetItem( - title = stringResource(filter.getResource()), - trailing = { - if (filter == currentFilter) { - MenuItemIcon( - id = R.drawable.ic_check_circle, - contentDescription = stringResource(R.string.label_selected), - tint = MaterialTheme.wireColorScheme.positive, - ) - } - }, - onItemClick = { onChangeFilter(filter) }, - onItemClickDescription = stringResource(R.string.content_description_select_label) - ) + when (filterSheetState.currentData.tab) { + FilterTab.FILTERS -> { + ConversationFiltersSheetContent( + sheetData = filterSheetState.currentData, + onChangeFilter = onChangeFilter, + showFoldersBottomSheet = { + filterSheetState.toFolders() } - } + ) } - ) + + FilterTab.FOLDERS -> { + ConversationFoldersSheetContent( + sheetData = filterSheetState.currentData, + onChangeFolder = onChangeFolder, + onBackClick = { + filterSheetState.toFilters() + } + ) + } + } + + BackHandler( + filterSheetState.currentData.tab == FilterTab.FOLDERS + && isBottomSheetVisible() + ) { + filterSheetState.toFilters() + } +} + +@Composable +fun rememberFilterSheetState( + filterSheetData: ConversationFilterSheetData, +): ConversationFilterSheetState { + return remember(filterSheetData) { + ConversationFilterSheetState( + conversationFilterSheetData = filterSheetData + ) + } } -private fun ConversationFilter.getResource(): Int = when (this) { - ConversationFilter.ALL -> R.string.label_filter_all - ConversationFilter.FAVORITES -> R.string.label_filter_favorites - ConversationFilter.GROUPS -> R.string.label_filter_group - ConversationFilter.ONE_ON_ONE -> R.string.label_filter_one_on_one +fun ConversationFilter.uiText(): UIText = when (this) { + ConversationFilter.All -> UIText.StringResource(R.string.label_filter_all) + ConversationFilter.Favorites -> UIText.StringResource(R.string.label_filter_favorites) + ConversationFilter.Groups -> UIText.StringResource(R.string.label_filter_group) + ConversationFilter.OneOnOne -> UIText.StringResource(R.string.label_filter_one_on_one) + is ConversationFilter.Folder -> UIText.StringResource(R.string.label_filter_folders, this.folderName) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetState.kt new file mode 100644 index 00000000000..28d98538142 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetState.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversationslist.filter + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.wire.kalium.logic.data.conversation.ConversationFilter +import com.wire.kalium.logic.data.conversation.ConversationFolder +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +class ConversationFilterSheetState( + conversationFilterSheetData: ConversationFilterSheetData = ConversationFilterSheetData( + currentFilter = ConversationFilter.All, + folders = persistentListOf() + ) +) { + var currentData: ConversationFilterSheetData by mutableStateOf(conversationFilterSheetData) + + fun toFolders() { + currentData = currentData.copy(tab = FilterTab.FOLDERS) + } + + fun toFilters() { + currentData = currentData.copy(tab = FilterTab.FILTERS) + } +} + +@Serializable +data class ConversationFilterSheetData( + val tab: FilterTab = FilterTab.FILTERS, + val currentFilter: ConversationFilter, + val folders: PersistentList +) + +enum class FilterTab { + FILTERS, + FOLDERS +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFiltersSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFiltersSheetContent.kt new file mode 100644 index 00000000000..1345db26b94 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFiltersSheetContent.kt @@ -0,0 +1,126 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversationslist.filter + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.RichMenuItemState +import com.wire.android.ui.common.bottomsheet.SelectableMenuBottomSheetItem +import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent +import com.wire.android.ui.common.dimensions +import com.wire.kalium.logic.data.conversation.ConversationFilter + +@Composable +fun ConversationFiltersSheetContent( + sheetData: ConversationFilterSheetData, + onChangeFilter: (ConversationFilter) -> Unit, + showFoldersBottomSheet: (selectedFolderId: String?) -> Unit +) { + WireMenuModalSheetContent( + header = MenuModalSheetHeader.Visible( + title = stringResource(R.string.label_filter_conversations), + customVerticalPadding = dimensions().spacing8x + ), + menuItems = buildList<@Composable () -> Unit> { + add { + val state = if (ConversationFilter.All == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.All.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.All) }, + ), + state = state + ) + } + add { + val state = if (ConversationFilter.Favorites == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.Favorites.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.Favorites) }, + ), + state = state + ) + } + add { + val state = if (ConversationFilter.Groups == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.Groups.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.Groups) }, + ), + state = state + ) + } + add { + val state = if (ConversationFilter.OneOnOne == sheetData.currentFilter) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = ConversationFilter.OneOnOne.uiText().asString(), + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFilter(ConversationFilter.OneOnOne) }, + ), + state = state + ) + } + add { + val state = if (sheetData.currentFilter is ConversationFilter.Folder) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = stringResource(R.string.label_filter_folders), + description = (sheetData.currentFilter as? ConversationFilter.Folder)?.folderName, + onItemClick = Clickable( + enabled = true, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { showFoldersBottomSheet((sheetData.currentFilter as? ConversationFilter.Folder)?.folderId) }, + ), + state = state + ) + } + } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt new file mode 100644 index 00000000000..e65797cbe8e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt @@ -0,0 +1,142 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversationslist.filter + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.ui.common.ArrowLeftIcon +import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.RichMenuItemState +import com.wire.android.ui.common.bottomsheet.SelectableMenuBottomSheetItem +import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.CustomTabsHelper +import com.wire.kalium.logic.data.conversation.ConversationFilter + +@Composable +fun ConversationFoldersSheetContent( + sheetData: ConversationFilterSheetData, + onChangeFolder: (ConversationFilter.Folder) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + WireMenuModalSheetContent( + modifier = modifier, + header = MenuModalSheetHeader.Visible( + title = stringResource(R.string.label_folders), + customVerticalPadding = dimensions().spacing8x, + leadingIcon = { + ArrowLeftIcon(modifier = Modifier.clickable { onBackClick() }) + Spacer(modifier = Modifier.width(dimensions().spacing8x)) + }, + includeDivider = sheetData.folders.isNotEmpty() + ), + menuItems = buildList<@Composable () -> Unit> { + if (sheetData.folders.isEmpty()) { + add { + EmptyFolders() + } + } else { + sheetData.folders.forEach { folder -> + add { + val state = if (sheetData.currentFilter is ConversationFilter.Folder) { + val currentFolder = sheetData.currentFilter + if (currentFolder.folderId == folder.id) { + RichMenuItemState.SELECTED + } else { + RichMenuItemState.DEFAULT + } + } else { + RichMenuItemState.DEFAULT + } + SelectableMenuBottomSheetItem( + title = folder.name, + onItemClick = Clickable( + enabled = state == RichMenuItemState.DEFAULT, + onClickDescription = stringResource(id = R.string.content_description_select_label), + onClick = { onChangeFolder(ConversationFilter.Folder(folder.name, folder.id)) } + ), + state = state + ) + } + } + } + } + ) +} + +@Composable +private fun EmptyFolders() { + val context = LocalContext.current + Box( + modifier = Modifier + .height(dimensions().spacing300x) + .fillMaxWidth(), + ) { + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(id = R.drawable.ic_folders_outline), + contentDescription = "", + tint = colorsScheme().secondaryText, + modifier = Modifier + .size(MaterialTheme.wireDimensions.spacing56x) + ) + VerticalSpace.x16() + Text( + text = stringResource(R.string.folders_empty_list_description), + style = typography().body01, + ) + VerticalSpace.x16() + val supportUrl = stringResource(id = R.string.url_how_to_add_folders) + Text( + text = stringResource(R.string.folders_empty_list_how_to_add), + style = MaterialTheme.wireTypography.body02.copy( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.onBackground + ), + modifier = Modifier.clickable { + CustomTabsHelper.launchUrl(context, supportUrl) + } + ) + VerticalSpace.x16() + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt index c242f0e3b81..45a7aad4391 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt @@ -17,4 +17,16 @@ */ package com.wire.android.ui.home.conversationslist.model -enum class ConversationsSource { MAIN, ARCHIVE, FAVORITES, GROUPS, ONE_ON_ONE } +import kotlinx.serialization.Serializable + +@Serializable +sealed class ConversationsSource { + + data object MAIN : ConversationsSource() + data object ARCHIVE : ConversationsSource() + data object FAVORITES : ConversationsSource() + data object GROUPS : ConversationsSource() + data object ONE_ON_ONE : ConversationsSource() + + data class FOLDER(val folderId: String, val folderName: String) : ConversationsSource() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt index b0316695bc3..c3c9ac03c89 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt @@ -141,7 +141,7 @@ fun DrawerItem( ) Text( style = MaterialTheme.wireTypography.button02, - text = stringResource(id = destination.title), + text = destination.title.asString(), textAlign = TextAlign.Start, color = contentColor, modifier = Modifier diff --git a/app/src/main/res/drawable/ic_folders_outline.xml b/app/src/main/res/drawable/ic_folders_outline.xml new file mode 100644 index 00000000000..c35fc71a253 --- /dev/null +++ b/app/src/main/res/drawable/ic_folders_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de630702afe..060d2d7a6c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,6 +272,7 @@ https://support.wire.com/hc/articles/6655706999581 https://support.wire.com/hc/articles/207859815 https://support.wire.com/hc/articles/360002855557 + https://support.wire.com/hc/articles/360002855817 https://wire.com/pricing https://teams.wire.com/ @@ -658,8 +659,10 @@ Filter Conversations All Conversations Favorites + Folders Groups 1:1 Conversations + Folders Everything @@ -1198,6 +1201,8 @@ In group conversations, the group admin can overwrite this setting. You are not part of any group conversation yet.\nStart a new conversation! You have no contacts yet.\nSearch for people on %1$s and get connected. How to label conversations as favorites + Add your conversations to folders to stay organized. + How to add a conversation to a folder Welcome 👋 No conversations could be found. Connect with new users or start a new conversation diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt index d60659aa43a..7d954ff464d 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt @@ -112,7 +112,7 @@ class GetConversationsFromSearchUseCaseTest { fromArchive = false, newActivitiesOnTop = false, onlyInteractionEnabled = false, - conversationFilter = ConversationFilter.FAVORITES + conversationFilter = ConversationFilter.Favorites ).asSnapshot() // Then diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index a1a0fc548fa..56bc7e134b5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -278,7 +278,7 @@ class ConversationListViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) - coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.ALL) } returns flowOf( + coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.All) } returns flowOf( listOf( TestConversationDetails.CONNECTION, TestConversationDetails.CONVERSATION_ONE_ONE, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt index 0ec6ea2b2db..fa793613b62 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt @@ -38,17 +38,20 @@ import com.wire.android.ui.common.divider.WireDivider import com.wire.android.ui.theme.wireTypography @Composable -fun ModalSheetHeaderItem(header: MenuModalSheetHeader = MenuModalSheetHeader.Gone) { +fun ModalSheetHeaderItem( + modifier: Modifier = Modifier, + header: MenuModalSheetHeader = MenuModalSheetHeader.Gone, +) { when (header) { MenuModalSheetHeader.Gone -> { - Spacer(modifier = Modifier.height(dimensions().modalBottomSheetNoHeaderVerticalPadding)) + Spacer(modifier = modifier.height(dimensions().modalBottomSheetNoHeaderVerticalPadding)) } is MenuModalSheetHeader.Visible -> { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding( + modifier = modifier.padding( start = dimensions().modalBottomSheetHeaderHorizontalPadding, end = dimensions().modalBottomSheetHeaderHorizontalPadding, top = header.customVerticalPadding ?: dimensions().modalBottomSheetHeaderVerticalPadding, @@ -63,7 +66,9 @@ fun ModalSheetHeaderItem(header: MenuModalSheetHeader = MenuModalSheetHeader.Gon modifier = Modifier.semantics { heading() } ) } - WireDivider() + if (header.includeDivider) { + WireDivider() + } } } } @@ -74,7 +79,8 @@ sealed class MenuModalSheetHeader { data class Visible( val title: String, val leadingIcon: @Composable () -> Unit = {}, - val customVerticalPadding: Dp? = null + val customVerticalPadding: Dp? = null, + val includeDivider: Boolean = true ) : MenuModalSheetHeader() object Gone : MenuModalSheetHeader() diff --git a/kalium b/kalium index d8b69f1202e..26b7d4b4dcf 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d8b69f1202e0ea88889c98bf1e2f9cd5016d197c +Subproject commit 26b7d4b4dcf2b50b64a3979e6211094a7a5d63d4