diff --git a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt index 0dfa1919d7..1f885594fc 100644 --- a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt +++ b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt @@ -20,6 +20,7 @@ package com.wire.android import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.calling.AutoCallStatusObserver import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.WireNotificationManager import com.wire.android.util.CurrentScreenManager @@ -61,12 +62,14 @@ class GlobalObserversManager @Inject constructor( private val notificationChannelsManager: NotificationChannelsManager, private val userDataStoreProvider: UserDataStoreProvider, private val currentScreenManager: CurrentScreenManager, + private val autoCallStatusObserver: AutoCallStatusObserver, ) { // TODO(tests): refactor so scope/dispatcher can be injected and properly stopped private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io()) fun observe() { scope.launch { setUpNotifications() } + scope.launch { autoCallStatusObserver.observe() } scope.launch { coreLogic.getGlobalScope().observeValidAccounts().distinctUntilChanged().collectLatest { coroutineScope { diff --git a/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt index 357b3779b4..9782a0f32e 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt @@ -29,6 +29,7 @@ import androidx.datastore.preferences.preferencesDataStore import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map @Suppress("TooManyFunctions") @@ -99,6 +100,43 @@ class UserDataStore(private val context: Context, userId: UserId) { context.dataStore.edit { it[IS_CREATE_TEAM_NOTICE_READ] = isRead } } + suspend fun saveAutoCallStatusSnapshot( + availabilityStatus: UserAvailabilityStatus, + textStatus: String? + ) { + context.dataStore.edit { preferences -> + preferences[AUTO_CALL_STATUS_ACTIVE] = true + preferences[AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY] = availabilityStatus.name + if (textStatus == null) { + preferences.remove(AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS) + } else { + preferences[AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS] = textStatus + } + } + } + + suspend fun getAutoCallStatusSnapshot(): AutoCallStatusSnapshot? = + context.dataStore.data.map { preferences -> + if (preferences[AUTO_CALL_STATUS_ACTIVE] != true) { + null + } else { + AutoCallStatusSnapshot( + availabilityStatus = preferences[AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY] + ?.let(UserAvailabilityStatus::valueOf) + ?: UserAvailabilityStatus.NONE, + textStatus = preferences[AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS] + ) + } + }.firstOrNull() + + suspend fun clearAutoCallStatusSnapshot() { + context.dataStore.edit { preferences -> + preferences.remove(AUTO_CALL_STATUS_ACTIVE) + preferences.remove(AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY) + preferences.remove(AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS) + } + } + companion object { private const val PREFERENCES_NAME = "user_data" @@ -113,5 +151,13 @@ class UserDataStore(private val context: Context, userId: UserId) { private val ANONYMOUS_ANALYTICS = booleanPreferencesKey("anonymous_analytics") private val ANALYTICS_DIALOG_SEEN = booleanPreferencesKey("analytics_dialog_seen") private val IS_CREATE_TEAM_NOTICE_READ = booleanPreferencesKey("is_create_team_notice_read") + private val AUTO_CALL_STATUS_ACTIVE = booleanPreferencesKey("auto_call_status_active") + private val AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY = stringPreferencesKey("auto_call_status_previous_availability") + private val AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS = stringPreferencesKey("auto_call_status_previous_text_status") } } + +data class AutoCallStatusSnapshot( + val availabilityStatus: UserAvailabilityStatus, + val textStatus: String? +) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt index e1c6cac7b4..b992521e48 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt @@ -54,6 +54,7 @@ import com.wire.kalium.logic.feature.user.UpdateAccentColorUseCase import com.wire.kalium.logic.feature.user.UpdateDisplayNameUseCase import com.wire.kalium.logic.feature.user.UpdateEmailUseCase import com.wire.kalium.logic.feature.user.UpdateSelfAvailabilityStatusUseCase +import com.wire.kalium.logic.feature.user.UpdateSelfTextStatusUseCase import com.wire.kalium.logic.feature.user.UploadUserAvatarUseCase import com.wire.kalium.logic.feature.user.UserScope import com.wire.kalium.logic.feature.user.readReceipts.ObserveReadReceiptsEnabledUseCase @@ -170,6 +171,10 @@ class UserModule { fun provideUpdateSelfAvailabilityStatusUseCase(userScope: UserScope): UpdateSelfAvailabilityStatusUseCase = userScope.updateSelfAvailabilityStatus + @Provides + fun provideUpdateSelfTextStatusUseCase(userScope: UserScope): UpdateSelfTextStatusUseCase = + userScope.updateSelfTextStatus + @Provides fun provideGetAllContactsUseCase( userScope: UserScope diff --git a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt index 9caf91f352..2867fdbfd4 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt @@ -162,6 +162,7 @@ import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel +import com.wire.android.ui.userprofile.self.status.SelfUserStatusViewModel import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel import com.wire.kalium.logic.data.id.ConversationId @@ -922,6 +923,12 @@ object WireMetroViewModelBindings { fun selfUserProfileViewModel(factory: SettingsViewModelFactory): ViewModel = factory.selfUserProfileViewModel() + @Provides + @IntoMap + @ViewModelKey(SelfUserStatusViewModel::class) + fun selfUserStatusViewModel(factory: SettingsViewModelFactory): ViewModel = + factory.selfUserStatusViewModel() + @Provides @IntoMap @ViewModelKey(TeamMigrationViewModel::class) diff --git a/app/src/main/kotlin/com/wire/android/feature/calling/AutoCallStatusObserver.kt b/app/src/main/kotlin/com/wire/android/feature/calling/AutoCallStatusObserver.kt new file mode 100644 index 0000000000..506edcf068 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/feature/calling/AutoCallStatusObserver.kt @@ -0,0 +1,192 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.feature.calling + +import android.content.Context +import com.wire.android.R +import com.wire.android.appLogger +import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.di.ApplicationContext +import com.wire.android.di.KaliumCoreLogic +import com.wire.android.ui.userprofile.self.status.buildTextStatus +import com.wire.kalium.common.functional.fold +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.call.Call +import com.wire.kalium.logic.data.user.SelfUser +import com.wire.kalium.logic.data.user.UserAvailabilityStatus +import com.wire.kalium.logic.data.user.UserId +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(AppScope::class) +class AutoCallStatusObserver @Inject constructor( + @ApplicationContext private val context: Context, + @KaliumCoreLogic private val coreLogic: CoreLogic, + private val userDataStoreProvider: UserDataStoreProvider, +) { + + private val userMutexes = ConcurrentHashMap() + + suspend fun observe() { + coreLogic.getGlobalScope().observeValidAccounts().collectLatest { validAccounts -> + kotlinx.coroutines.coroutineScope { + validAccounts.forEach { (selfUser, team) -> + launch { + observeAccountCallStatus(selfUser.id, team != null) + } + } + } + } + } + + private suspend fun observeAccountCallStatus(userId: UserId, isTeamMember: Boolean) { + val sessionScope = coreLogic.getSessionScope(userId) + combine( + sessionScope.calls.establishedCall(), + sessionScope.users.observeSelfUserWithTeam() + ) { establishedCalls, selfWithTeam -> + AccountCallStatusState( + isTeamMember = isTeamMember && selfWithTeam.second != null, + establishedCalls = establishedCalls, + selfUser = selfWithTeam.first, + ) + }.collectLatest { state -> + reconcile(userId, state) + } + } + + private suspend fun reconcile(userId: UserId, state: AccountCallStatusState) { + userMutex(userId).withLock { + val dataStore = userDataStoreProvider.getOrCreate(userId) + val snapshot = dataStore.getAutoCallStatusSnapshot() + val hasEstablishedCall = state.establishedCalls.isNotEmpty() + + if (!state.isTeamMember) { + if (snapshot != null) { + if (restoreSnapshot(userId, state.selfUser, snapshot.availabilityStatus, snapshot.textStatus)) { + dataStore.clearAutoCallStatusSnapshot() + } + } + return + } + + if (hasEstablishedCall) { + if (snapshot != null) { + if (!isAutoCallStatusApplied(state.selfUser)) { + applyMeetingStatus(userId, state.selfUser) + } + return + } + + dataStore.saveAutoCallStatusSnapshot( + availabilityStatus = state.selfUser.availabilityStatus, + textStatus = state.selfUser.textStatus + ) + applyMeetingStatus(userId, state.selfUser) + return + } + + if (snapshot != null) { + if (restoreSnapshot(userId, state.selfUser, snapshot.availabilityStatus, snapshot.textStatus)) { + dataStore.clearAutoCallStatusSnapshot() + } + } + } + } + + private suspend fun applyMeetingStatus(userId: UserId, selfUser: SelfUser): Boolean { + val statusText = buildTextStatus( + emoji = QUICK_STATUS_IN_MEETING_EMOJI, + message = context.getString(R.string.user_profile_quick_status_in_meeting) + ) + val userScope = coreLogic.getSessionScope(userId).users + var success = true + + if (selfUser.availabilityStatus != AUTO_CALL_AVAILABILITY) { + userScope.updateSelfAvailabilityStatus(AUTO_CALL_AVAILABILITY) + } + + if (selfUser.textStatus != statusText) { + userScope.updateSelfTextStatus(statusText) + .fold( + fnL = { + success = false + appLogger.e("Failed to update auto call text status for $userId: $it") + }, + fnR = {} + ) + } + + return success + } + + private suspend fun restoreSnapshot( + userId: UserId, + selfUser: SelfUser, + availabilityStatus: UserAvailabilityStatus, + textStatus: String? + ): Boolean { + val userScope = coreLogic.getSessionScope(userId).users + var success = true + + if (selfUser.availabilityStatus != availabilityStatus) { + userScope.updateSelfAvailabilityStatus(availabilityStatus) + } + + if (selfUser.textStatus != textStatus) { + userScope.updateSelfTextStatus(textStatus) + .fold( + fnL = { + success = false + appLogger.e("Failed to restore text status after call for $userId: $it") + }, + fnR = {} + ) + } + + return success + } + + private fun userMutex(userId: UserId): Mutex = userMutexes.getOrPut(userId) { Mutex() } + + private fun isAutoCallStatusApplied(selfUser: SelfUser): Boolean = + selfUser.availabilityStatus == AUTO_CALL_AVAILABILITY && + selfUser.textStatus == buildTextStatus( + emoji = QUICK_STATUS_IN_MEETING_EMOJI, + message = context.getString(R.string.user_profile_quick_status_in_meeting) + ) + + private data class AccountCallStatusState( + val isTeamMember: Boolean, + val establishedCalls: List, + val selfUser: SelfUser, + ) + + companion object { + private val AUTO_CALL_AVAILABILITY = UserAvailabilityStatus.BUSY + private const val QUICK_STATUS_IN_MEETING_EMOJI = "\uD83C\uDFA7" + } +} diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt index 6ed92f5f91..71a24aa283 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -168,6 +168,7 @@ class MessageMapper @Inject constructor( clientId = (message as? Message.Sendable)?.senderClientId, accent = Accent.fromAccentId(sender?.accentId), guestExpiresAt = sender?.expiresAt, + textStatus = sender?.textStatus, senderId = when { (sender as? OtherUser)?.botService != null -> MessageSenderId.Bot(sender.botService!!) sender?.userType == UserTypeInfo.App -> MessageSenderId.App(sender.id) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt index e55396ae4c..06099b785b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt @@ -59,6 +59,16 @@ fun MessageAuthorRow( messageStyle, modifier = Modifier.weight(weight = 1f, fill = false) ) + if (!textStatus.isNullOrBlank()) { + Text( + text = textStatus, + style = MaterialTheme.wireTypography.body02, + color = MaterialTheme.wireColorScheme.onBackground.copy(alpha = 0.6f), + modifier = Modifier.padding(start = dimensions().spacing4x), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } UserBadge( membership = membership, connectionState = connectionState, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index 0ec5d9b526..8f618b1536 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -165,7 +165,8 @@ data class MessageHeader( val clientId: ClientId? = null, val accent: Accent = Accent.Unknown, val guestExpiresAt: Instant? = null, - val senderId: MessageSenderId? = null + val senderId: MessageSenderId? = null, + val textStatus: String? = null ) sealed interface MessageSenderId { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelFactory.kt index 3ab2d6d90f..d7d7292bd1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelFactory.kt @@ -45,6 +45,7 @@ import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel +import com.wire.android.ui.userprofile.self.status.SelfUserStatusViewModel import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel import com.wire.android.util.dispatchers.DispatcherProvider @@ -101,6 +102,7 @@ class SettingsViewModelFactory @Inject constructor( private val selfDevicesViewModel: Provider, private val avatarPickerViewModel: Provider, private val selfUserProfileViewModel: Provider, + private val selfUserStatusViewModel: Provider, private val teamMigrationViewModel: Provider, private val updateEmail: Provider, private val getSelfUser: Provider, @@ -155,6 +157,7 @@ class SettingsViewModelFactory @Inject constructor( fun selfDevicesViewModel(): SelfDevicesViewModel = selfDevicesViewModel.invoke() fun avatarPickerViewModel(): AvatarPickerViewModel = avatarPickerViewModel.invoke() fun selfUserProfileViewModel(): SelfUserProfileViewModel = selfUserProfileViewModel.invoke() + fun selfUserStatusViewModel(): SelfUserStatusViewModel = selfUserStatusViewModel.invoke() fun teamMigrationViewModel(): TeamMigrationViewModel = teamMigrationViewModel.invoke() fun verifyEmailViewModel(savedStateHandle: SavedStateHandle): VerifyEmailViewModel = VerifyEmailViewModel( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt index 198d93ebb6..dfd3cb9537 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt @@ -46,6 +46,7 @@ import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel +import com.wire.android.ui.userprofile.self.status.SelfUserStatusViewModel import com.wire.android.ui.userprofile.service.ServiceDetailsViewModel import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel @@ -153,6 +154,10 @@ fun avatarPickerViewModel(): AvatarPickerViewModel = fun selfUserProfileViewModel(): SelfUserProfileViewModel = settingsViewModel() +@Composable +fun selfUserStatusViewModel(): SelfUserStatusViewModel = + settingsViewModel() + @Composable fun selfQRCodeViewModel(): SelfQRCodeViewModel = settingsSavedStateViewModel() diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt index b72ea84c59..50219039c6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout @@ -119,6 +120,9 @@ fun UserProfileInfo( onQrCodeClick: (() -> Unit)? = null, accentId: Int = -1, showQrCode: Boolean = true, + textStatus: String? = null, + availabilityStatus: UserAvailabilityStatus = UserAvailabilityStatus.NONE, + avatarStatusSize: Dp = dimensions().avatarStatusSize, ) { Column( horizontalAlignment = CenterHorizontally, @@ -131,6 +135,7 @@ fun UserProfileInfo( Box(contentAlignment = Alignment.Center) { val userAvatarData = UserAvatarData( asset = avatarAsset, + availabilityStatus = availabilityStatus, connectionState = connection, membership = membership, nameBasedAvatar = NameBasedAvatar(fullName, accentId) @@ -156,6 +161,7 @@ fun UserProfileInfo( UserProfileAvatar( size = dimensions().avatarDefaultBigSize, temporaryUserBorderWidth = dimensions().avatarBigTemporaryUserBorderWidth, + statusSize = avatarStatusSize, avatarData = userAvatarData, clickable = remember(editableState) { Clickable( @@ -167,7 +173,7 @@ fun UserProfileInfo( showPlaceholderIfNoAsset = showPlaceholderIfNoAsset, withCrossfadeAnimation = true, type = expiresAt?.let { UserProfileAvatarType.WithIndicators.TemporaryUser(expiresAt) } - ?: UserProfileAvatarType.WithoutIndicators, + ?: UserProfileAvatarType.WithIndicators.RegularUser(legalHoldIndicatorVisible = false), contentDescription = contentDescription ) } @@ -283,13 +289,27 @@ fun UserProfileInfo( val teamDescription = stringResource(R.string.content_description_self_profile_team, teamName) TeamInformation( modifier = Modifier - .padding(top = dimensions().spacing8x) + .padding(vertical = dimensions().spacing8x) .padding(horizontal = dimensions().spacing16x) .semantics(mergeDescendants = true) { contentDescription = teamDescription }, teamName = teamName ) } + if (teamName != null && !textStatus.isNullOrBlank()) { + Text( + modifier = Modifier + .padding(vertical = dimensions().spacing8x) + .padding(horizontal = dimensions().spacing16x), + text = textStatus, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.colorScheme.onBackground + ) + } + userId?.also { SecurityClassificationBannerForUser( userId = it, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index a978eebf42..1b5dd0916e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -390,7 +390,8 @@ private fun TopBarCollapsing( isProteusVerified = targetState.isProteusVerified, isMLSVerified = targetState.isMLSVerified, expiresAt = targetState.expiresAt, - accentId = targetState.accentId + accentId = targetState.accentId, + textStatus = targetState.textStatus, ) if (state.isUnderLegalHold) { LegalHoldSubjectBanner( diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt index db6b84886c..988c099193 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt @@ -228,6 +228,7 @@ class OtherUserProfileScreenViewModel @Inject constructor( fullName = otherUser.name.orEmpty(), userName = fromOtherUser(otherUser), teamName = userResult.team?.name.orEmpty(), + textStatus = otherUser.textStatus, email = otherUser.email.orEmpty(), phone = otherUser.phone.orEmpty(), connectionState = otherUser.connectionStatus, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt index 42fd7a3698..f218aee2c8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt @@ -41,6 +41,7 @@ data class OtherUserProfileState( val fullName: String = "", val userName: String = "", val teamName: String = "", + val textStatus: String? = null, val email: String = "", val phone: String = "", val connectionState: ConnectionState = ConnectionState.NOT_CONNECTED, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt index 7e53eee371..bef670f85b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt @@ -38,6 +38,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,8 +64,6 @@ import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.ramcosta.composedestinations.generated.app.navgraphs.PersonalToTeamMigrationGraph import com.wire.android.ui.common.VisibilityState -import com.wire.android.ui.common.WireDropDown -import com.wire.android.ui.common.avatar.UserStatusIndicator import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton @@ -81,8 +81,8 @@ import com.ramcosta.composedestinations.generated.app.destinations.AvatarPickerS import com.ramcosta.composedestinations.generated.app.destinations.MyAccountScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.SelfQRCodeScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.SelfUserStatusScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination -import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.legalhold.banner.LegalHoldPendingBanner import com.wire.android.ui.legalhold.banner.LegalHoldSubjectBanner import com.wire.android.ui.legalhold.banner.LegalHoldUIState @@ -98,7 +98,6 @@ import com.wire.android.ui.userprofile.common.EditableState import com.wire.android.ui.userprofile.common.UserProfileInfo import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel.ErrorCodes import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel.ErrorCodes.DownloadUserInfoError -import com.wire.android.ui.userprofile.self.dialog.ChangeStatusDialogContent import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.ui.userprofile.self.model.OtherAccount @@ -119,11 +118,15 @@ fun SelfUserProfileScreen( navigator: Navigator, loginTypeSelector: LoginTypeSelector, avatarPickerResultRecipient: ResultRecipient, + statusUpdateResultRecipient: ResultRecipient, viewModelSelf: SelfUserProfileViewModel = selfUserProfileViewModel(), legalHoldRequestedViewModel: LegalHoldRequestedViewModel = legalHoldRequestedViewModel() ) { val legalHoldSubjectDialogState = rememberVisibilityState() val logoutAction = LocalSelfUserProfileLogoutAction.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + val statusUpdatedMessage = stringResource(R.string.user_profile_status_updated) LaunchedEffect(Unit) { // Check if the user is able to migrate to a team account, every time the screen is shown @@ -145,14 +148,13 @@ fun SelfUserProfileScreen( ) }, onEditClick = { navigator.navigate(NavigationCommand(AppSettingsScreenDestination)) }, - onStatusClicked = viewModelSelf::changeStatusClick, + onUpdateStatusClick = { + navigator.navigate(NavigationCommand(SelfUserStatusScreenDestination)) + }, onAddAccountClick = { val destination = if (loginTypeSelector.canUseNewLogin()) NewLoginScreenDestination() else WelcomeScreenDestination() navigator.navigate(NavigationCommand(destination)) }, - dismissStatusDialog = viewModelSelf::dismissStatusDialog, - onStatusChange = viewModelSelf::changeStatus, - onNotShowRationaleAgainChange = viewModelSelf::dialogCheckBoxStateChanged, onMessageShown = viewModelSelf::clearErrorMessage, onLegalHoldAcceptClick = legalHoldRequestedViewModel::show, onLegalHoldLearnMoreClick = remember { { legalHoldSubjectDialogState.show(Unit) } }, @@ -193,6 +195,12 @@ fun SelfUserProfileScreen( } } + statusUpdateResultRecipient.onNavResult { result -> + if (result is NavResult.Value && result.value) { + coroutineScope.launch { snackbarHostState.showSnackbar(statusUpdatedMessage) } + } + } + if (legalHoldRequestedViewModel.state is LegalHoldRequestedState.Visible) { LegalHoldRequestedDialog( state = legalHoldRequestedViewModel.state as LegalHoldRequestedState.Visible, @@ -213,11 +221,8 @@ private fun SelfUserProfileContent( logout: (Boolean) -> Unit = {}, onChangeUserProfilePicture: () -> Unit = {}, onEditClick: () -> Unit = {}, - onStatusClicked: (UserAvailabilityStatus) -> Unit = {}, + onUpdateStatusClick: () -> Unit = {}, onAddAccountClick: () -> Unit = {}, - dismissStatusDialog: () -> Unit = {}, - onStatusChange: (UserAvailabilityStatus) -> Unit = {}, - onNotShowRationaleAgainChange: (Boolean) -> Unit = {}, onMessageShown: () -> Unit = {}, onLegalHoldAcceptClick: () -> Unit = {}, onLegalHoldLearnMoreClick: () -> Unit = {}, @@ -252,6 +257,21 @@ private fun SelfUserProfileContent( } } ) + }, + bottomBar = { + val context = LocalContext.current + Column { + Divider(color = MaterialTheme.wireColorScheme.outline) + Column( + modifier = Modifier.padding(dimensions().spacing16x), + verticalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + if (state.teamUrl != null) { + ManageTeamButton { uriHandler.openUri(state.teamUrl) } + } + NewTeamButton(onAddAccountClick, isUserInCall, context) + } + } } ) { internalPadding -> with(state) { @@ -288,11 +308,14 @@ private fun SelfUserProfileContent( fullName = fullName, userName = userName, teamName = teamName, + textStatus = textStatus, onUserProfileClick = onChangeUserProfilePicture, editableState = EditableState.IsEditable(onEditClick), onQrCodeClick = onQrCodeClick, accentId = accentId, showQrCode = state.showQrCode, + availabilityStatus = status, + avatarStatusSize = MaterialTheme.wireDimensions.avatarStatusSize + 2.dp, ) if (state.legalHoldStatus != LegalHoldUIState.None) { Box( @@ -311,10 +334,9 @@ private fun SelfUserProfileContent( } } if (!state.teamName.isNullOrBlank()) { - CurrentSelfUserStatus( - userStatus = status, - onStatusClicked = onStatusClicked, - ) + Box(modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.spacing16x)) { + UpdateStatusButton(onUpdateStatusClick = onUpdateStatusClick) + } } VerticalSpace.x8() Box(modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.spacing16x)) { @@ -347,26 +369,7 @@ private fun SelfUserProfileContent( } } } - - Divider(color = MaterialTheme.wireColorScheme.outline) - - Column( - modifier = Modifier.padding(dimensions().spacing16x), - verticalArrangement = Arrangement.spacedBy(dimensions().spacing8x) - ) { - if (teamUrl != null) { - ManageTeamButton { uriHandler.openUri(teamUrl) } - } - NewTeamButton(onAddAccountClick, isUserInCall, context) - } } - ChangeStatusDialogContent( - data = statusDialogData, - dismiss = dismissStatusDialog, - onStatusChange = onStatusChange, - onNotShowRationaleAgainChange = onNotShowRationaleAgainChange - ) - LogoutOptionsDialog( dialogState = logoutOptionsDialogState, logout = logout @@ -414,43 +417,6 @@ private fun SelfUserProfileTopBar( ) } -@Composable -private fun CurrentSelfUserStatus( - userStatus: UserAvailabilityStatus, - onStatusClicked: (UserAvailabilityStatus) -> Unit, -) { - val items = listOf( - UserAvailabilityStatus.AVAILABLE, - UserAvailabilityStatus.BUSY, - UserAvailabilityStatus.AWAY, - UserAvailabilityStatus.NONE - ) - - Column(modifier = Modifier.fillMaxWidth()) { - SectionHeader(stringResource(R.string.user_profile_status_availability)) - - WireDropDown( - items = items.map { - when (it) { - UserAvailabilityStatus.AVAILABLE -> stringResource(UICommonR.string.user_profile_status_available) - UserAvailabilityStatus.BUSY -> stringResource(UICommonR.string.user_profile_status_busy) - UserAvailabilityStatus.AWAY -> stringResource(UICommonR.string.user_profile_status_away) - UserAvailabilityStatus.NONE -> stringResource(UICommonR.string.user_profile_status_none) - } - }, - defaultItemIndex = items.indexOf(userStatus), - label = null, - modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.spacing16x), - autoUpdateSelection = false, - showDefaultTextIndicator = false, - leadingCompose = { index -> UserStatusIndicator(items[index]) }, - onChangeClickDescription = stringResource(R.string.content_description_self_profile_change_status) - ) { selectedIndex -> - onStatusClicked(items[selectedIndex]) - } - } -} - @Composable private fun ManageTeamButton( onManageTeamClick: () -> Unit @@ -489,6 +455,29 @@ private fun NewTeamButton( ) } +@Composable +private fun UpdateStatusButton( + onUpdateStatusClick: () -> Unit, +) { + WireSecondaryButton( + modifier = Modifier + .testTag("Update Status"), + text = stringResource(R.string.user_profile_update_status), + trailingIcon = { + Icon( + painter = painterResource(id = UICommonR.drawable.ic_arrow_right), + contentDescription = "", + tint = MaterialTheme.wireColorScheme.onSecondaryButtonEnabled, + modifier = Modifier + .defaultMinSize(dimensions().wireIconButtonSize) + .padding(end = dimensions().spacing8x) + ) + }, + onClickDescription = stringResource(R.string.content_description_self_profile_update_status), + onClick = onUpdateStatusClick, + ) +} + @Composable private fun AccountDetailButton( onAccountDetailsClick: () -> Unit, @@ -562,7 +551,6 @@ fun PersonalSelfUserProfileScreenPreview() { SelfUserProfileContent( SelfUserProfileState( userId = UserId("value", "domain"), - status = UserAvailabilityStatus.BUSY, fullName = "Some User", userName = "some-user", teamName = null, @@ -591,10 +579,7 @@ fun PersonalSelfUserProfileScreenPreview() { @Composable fun PreviewCurrentSelfUserStatus() { WireTheme { - CurrentSelfUserStatus( - UserAvailabilityStatus.AVAILABLE, - onStatusClicked = {}, - ) + UpdateStatusButton(onUpdateStatusClick = {}) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt index f25e5c3b8e..eac46bfbf3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt @@ -31,6 +31,7 @@ data class SelfUserProfileState( val avatarAsset: UserAvatarAsset? = null, val errorMessageCode: ErrorCodes? = null, val status: UserAvailabilityStatus = UserAvailabilityStatus.NONE, + val textStatus: String? = null, val fullName: String = "", val userName: String = "", val teamName: String? = "", // maybe teamId is better here diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt index eb35831588..23a2b58cdf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt @@ -172,6 +172,7 @@ class SelfUserProfileViewModel @Inject constructor( // Update user data state userProfileState = userProfileState.copy( status = availabilityStatus, + textStatus = textStatus, fullName = name.orEmpty(), userName = handle.orEmpty(), teamName = selfTeam?.name, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusScreen.kt new file mode 100644 index 0000000000..fda986b07d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusScreen.kt @@ -0,0 +1,443 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.self.status + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.wire.android.R +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.annotation.app.WireRootDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.WireDropDown +import com.wire.android.ui.common.avatar.UserStatusIndicator +import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.bottomsheet.show +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.textfield.WireTextField +import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.common.textfield.textAsFlow +import com.wire.android.ui.common.textfield.wireTextFieldColors +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.emoji.EmojiPickerBottomSheet +import com.wire.android.ui.home.settings.selfUserStatusViewModel +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.ui.userprofile.self.dialog.ChangeStatusDialogContent +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.user.UserAvailabilityStatus +import kotlinx.coroutines.flow.distinctUntilChanged +import com.wire.android.ui.common.R as UICommonR + +@WireRootDestination( + style = PopUpNavigationAnimation::class, +) +@Composable +@SuppressLint("ComposeModifierMissing") +fun SelfUserStatusScreen( + navigator: Navigator, + resultNavigator: ResultBackNavigator, + viewModel: SelfUserStatusViewModel = selfUserStatusViewModel(), +) { + LaunchedEffect(viewModel) { + viewModel.statusUpdated.collect { success -> + resultNavigator.setResult(success) + resultNavigator.navigateBack() + } + } + + SelfUserStatusContent( + state = viewModel.state, + onBackClick = navigator::navigateBack, + onAvailabilityStatusClicked = viewModel::changeAvailabilityStatusClick, + onAvailabilityStatusChange = viewModel::changeAvailabilityStatus, + onDismissStatusDialog = viewModel::dismissStatusDialog, + onNotShowRationaleAgainChange = viewModel::dialogCheckBoxStateChanged, + onEmojiSelected = viewModel::onEmojiSelected, + onQuickStatusSelected = viewModel::onQuickStatusSelected, + onClearCustomStatus = viewModel::clearCustomStatus, + onUpdateCustomStatus = viewModel::updateCustomStatus, + ) +} + +@Composable +private fun SelfUserStatusContent( + state: SelfUserStatusState, + onBackClick: () -> Unit, + onAvailabilityStatusClicked: (UserAvailabilityStatus) -> Unit, + onAvailabilityStatusChange: (UserAvailabilityStatus) -> Unit, + onDismissStatusDialog: () -> Unit, + onNotShowRationaleAgainChange: (Boolean) -> Unit, + onEmojiSelected: (String) -> Unit, + onQuickStatusSelected: (String, String) -> Unit, + onClearCustomStatus: () -> Unit, + onUpdateCustomStatus: (String) -> Unit, +) { + val textState = rememberTextFieldState(state.message) + val emojiPickerState = rememberWireModalSheetState(skipPartiallyExpanded = false) + val hasUnsavedChanges = state.isTeamMember && + (textState.text.toString() != state.savedMessage || state.emoji != state.savedEmoji) + + LaunchedEffect(state.message) { + if (textState.text.toString() != state.message) { + textState.setTextAndPlaceCursorAtEnd(state.message) + } + } + + LaunchedEffect(textState, state.emoji) { + textState.textAsFlow().distinctUntilChanged().collect { text -> + if (text.isNotBlank() && state.emoji == null) { + onEmojiSelected(DEFAULT_STATUS_EMOJI) + } + } + } + + WireScaffold( + containerColor = MaterialTheme.wireColorScheme.background, + topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = onBackClick, + title = stringResource(R.string.user_profile_status_title), + navigationIconType = NavigationIconType.Back(), + elevation = 0.dp, + ) + }, + bottomBar = { + Surface( + shadowElevation = MaterialTheme.wireDimensions.bottomNavigationShadowElevation, + color = MaterialTheme.wireColorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + WirePrimaryButton( + modifier = Modifier.padding(dimensions().spacing16x), + text = stringResource(R.string.user_profile_update_status), + loading = state.isSaving, + state = if (hasUnsavedChanges) WireButtonState.Default else WireButtonState.Disabled, + onClick = { onUpdateCustomStatus(textState.text.toString()) }, + ) + } + } + ) { internalPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(internalPadding) + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing12x), + verticalArrangement = Arrangement.spacedBy(dimensions().spacing16x), + ) { + AvailabilityCard( + userStatus = state.availabilityStatus, + onStatusClicked = onAvailabilityStatusClicked, + ) + if (state.isTeamMember) { + CustomStatusCard( + emoji = resolveStatusEmoji(state.emoji, textState.text.toString()), + textState = textState, + messageLength = textState.text.length, + onEmojiClick = { emojiPickerState.show() }, + onClearCustomStatus = { + textState.clearText() + onClearCustomStatus() + } + ) + QuickStatusSection( + presets = state.quickStatusPresets, + onQuickStatusSelected = onQuickStatusSelected, + ) + } + } + + ChangeStatusDialogContent( + data = state.statusDialogData, + dismiss = onDismissStatusDialog, + onStatusChange = onAvailabilityStatusChange, + onNotShowRationaleAgainChange = onNotShowRationaleAgainChange + ) + + EmojiPickerBottomSheet( + sheetState = emojiPickerState, + onEmojiSelected = { emoji, _ -> + emojiPickerState.hide() + onEmojiSelected(emoji) + } + ) + } +} + +@SuppressLint("ComposeModifierMissing") +@Composable +fun SelectedAvailabilityDescription(status: UserAvailabilityStatus) { + val description = when (status) { + UserAvailabilityStatus.NONE -> R.string.user_profile_change_status_dialog_none_text + UserAvailabilityStatus.AVAILABLE -> R.string.user_profile_change_status_dialog_available_text + UserAvailabilityStatus.BUSY -> R.string.user_profile_change_status_dialog_busy_text + UserAvailabilityStatus.AWAY -> R.string.user_profile_change_status_dialog_away_text + } + Text( + text = stringResource(description), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing8x, vertical = dimensions().spacing8x), + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.secondaryText, + ) +} + +@Composable +private fun SectionLabel(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + modifier = modifier.padding(bottom = dimensions().spacing4x), + style = MaterialTheme.wireTypography.label02, + color = MaterialTheme.wireColorScheme.secondaryText, + ) +} + +@Composable +private fun AvailabilityCard( + userStatus: UserAvailabilityStatus, + onStatusClicked: (UserAvailabilityStatus) -> Unit, +) { + val items = listOf( + UserAvailabilityStatus.AVAILABLE, + UserAvailabilityStatus.BUSY, + UserAvailabilityStatus.AWAY, + UserAvailabilityStatus.NONE + ) + + Column(modifier = Modifier.fillMaxWidth()) { + SectionLabel(stringResource(R.string.user_profile_status_availability)) + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(dimensions().spacing12x), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.wireColorScheme.surface, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dimensions().spacing16x) + ) { + WireDropDown( + items = items.map { + when (it) { + UserAvailabilityStatus.AVAILABLE -> stringResource(UICommonR.string.user_profile_status_available) + UserAvailabilityStatus.BUSY -> stringResource(UICommonR.string.user_profile_status_busy) + UserAvailabilityStatus.AWAY -> stringResource(UICommonR.string.user_profile_status_away) + UserAvailabilityStatus.NONE -> stringResource(UICommonR.string.user_profile_status_none) + } + }, + defaultItemIndex = items.indexOf(userStatus), + label = null, + autoUpdateSelection = false, + showDefaultTextIndicator = false, + leadingCompose = { index -> UserStatusIndicator(items[index]) }, + onChangeClickDescription = stringResource(R.string.content_description_self_profile_change_status) + ) { selectedIndex -> + onStatusClicked(items[selectedIndex]) + } + SelectedAvailabilityDescription(userStatus) + } + } + } +} + +@Composable +private fun CustomStatusCard( + emoji: String?, + textState: TextFieldState, + messageLength: Int, + onEmojiClick: () -> Unit, + onClearCustomStatus: () -> Unit, +) { + val hasStatus = emoji != null || textState.text.isNotBlank() + + Column(modifier = Modifier.fillMaxWidth()) { + SectionLabel(stringResource(R.string.user_profile_custom_status_header)) + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(dimensions().spacing12x), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.wireColorScheme.surface, + ), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + WireTextField( + textState = textState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing12x, vertical = dimensions().spacing12x), + placeholderText = stringResource(R.string.user_profile_custom_status_placeholder), + leadingIcon = { + IconButton(onClick = onEmojiClick) { + if (emoji == null) { + Icon( + painter = painterResource(id = R.drawable.ic_emoticon), + contentDescription = null, + ) + } else { + Text( + text = emoji, + style = MaterialTheme.wireTypography.title02, + ) + } + } + }, + state = WireTextFieldState.Default, + colors = wireTextFieldColors( + backgroundColor = MaterialTheme.wireColorScheme.surfaceContainerLow, + ), + trailingIcon = { + AnimatedVisibility( + visible = hasStatus, + enter = fadeIn(), + exit = fadeOut(), + ) { + IconButton(onClick = onClearCustomStatus) { + Icon( + painter = painterResource(id = UICommonR.drawable.ic_clear_search), + contentDescription = stringResource(UICommonR.string.content_description_clear_content), + ) + } + } + }, + inputTransformation = InputTransformation.maxLength(MAX_STATUS_TEXT_LENGTH), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done, + ), + ) + AnimatedVisibility(visible = messageLength > 0) { + Text( + text = stringResource(R.string.user_profile_custom_status_counter, messageLength, MAX_STATUS_TEXT_LENGTH), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x), + style = MaterialTheme.wireTypography.label04, + color = MaterialTheme.wireColorScheme.secondaryText, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun QuickStatusSection( + presets: List, + onQuickStatusSelected: (String, String) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + SectionLabel(stringResource(R.string.user_profile_quick_status_header)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x), + verticalArrangement = Arrangement.spacedBy(dimensions().spacing4x), + ) { + presets.forEach { preset -> + val presetLabel = stringResource(preset.labelResId) + AssistChip( + onClick = { onQuickStatusSelected(preset.emoji, presetLabel) }, + label = { + Text( + text = "${preset.emoji} $presetLabel", + style = MaterialTheme.wireTypography.body02, + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.wireColorScheme.surfaceContainerLow, + labelColor = MaterialTheme.wireColorScheme.onBackground, + ), + border = null, + ) + } + } + } +} + +@PreviewMultipleThemes +@Composable +private fun SelfUserStatusContentPreview() { + WireTheme { + SelfUserStatusContent( + state = SelfUserStatusState( + availabilityStatus = UserAvailabilityStatus.AVAILABLE, + emoji = DEFAULT_STATUS_EMOJI, + message = "Working from home", + isTeamMember = true, + ), + onBackClick = {}, + onAvailabilityStatusClicked = {}, + onAvailabilityStatusChange = {}, + onDismissStatusDialog = {}, + onNotShowRationaleAgainChange = {}, + onEmojiSelected = {}, + onQuickStatusSelected = { _, _ -> }, + onClearCustomStatus = {}, + onUpdateCustomStatus = {}, + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusState.kt new file mode 100644 index 0000000000..ff6d17ac0e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusState.kt @@ -0,0 +1,77 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.self.status + +import androidx.annotation.StringRes +import com.wire.android.R +import com.wire.android.ui.userprofile.self.dialog.StatusDialogData +import com.wire.kalium.logic.data.user.UserAvailabilityStatus + +data class SelfUserStatusState( + val availabilityStatus: UserAvailabilityStatus = UserAvailabilityStatus.NONE, + val emoji: String? = null, + val message: String = "", + val savedEmoji: String? = null, + val savedMessage: String = "", + val isTeamMember: Boolean = false, + val quickStatusPresets: List = defaultQuickStatusPresets(), + val statusDialogData: StatusDialogData? = null, + val isSaving: Boolean = false, +) + +data class QuickStatusPreset( + val emoji: String, + @StringRes val labelResId: Int, +) + +fun defaultQuickStatusPresets(): List = listOf( + QuickStatusPreset( + emoji = "\uD83C\uDFA7", + labelResId = R.string.user_profile_quick_status_in_meeting, + ), + QuickStatusPreset( + emoji = "\uD83E\uDD15", + labelResId = R.string.user_profile_quick_status_out_sick, + ), + QuickStatusPreset( + emoji = "\uD83D\uDCF5", + labelResId = R.string.user_profile_quick_status_out_of_office, + ), +) + +const val DEFAULT_STATUS_EMOJI = "\uD83D\uDCAC" +const val MAX_STATUS_TEXT_LENGTH = 50 + +fun resolveStatusEmoji(emoji: String?, message: String): String? = + if (!emoji.isNullOrBlank()) { + emoji + } else if (message.trim().isBlank()) { + null + } else { + DEFAULT_STATUS_EMOJI + } + +fun buildTextStatus(emoji: String?, message: String): String? { + val trimmedMessage = message.trim().take(MAX_STATUS_TEXT_LENGTH) + val trimmedEmoji = emoji?.takeIf { it.isNotBlank() } + if (trimmedMessage.isBlank()) return trimmedEmoji ?: " " + + val resolvedEmoji = resolveStatusEmoji(trimmedEmoji, trimmedMessage) ?: return trimmedMessage + return "$resolvedEmoji $trimmedMessage" +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusViewModel.kt new file mode 100644 index 0000000000..bd7fcba518 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusViewModel.kt @@ -0,0 +1,213 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.self.status + +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.datastore.UserDataStoreProvider +import com.wire.android.di.CurrentAccount +import com.wire.android.ui.userprofile.self.dialog.StatusDialogData +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.common.functional.fold +import com.wire.kalium.logic.data.user.UserAvailabilityStatus +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.user.ObserveSelfUserWithTeamUseCase +import com.wire.kalium.logic.feature.user.UpdateSelfAvailabilityStatusUseCase +import com.wire.kalium.logic.feature.user.UpdateSelfTextStatusUseCase +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch + +class SelfUserStatusViewModel @Inject constructor( + @CurrentAccount selfUserId: UserId, + userDataStoreProvider: UserDataStoreProvider, + private val observeSelfUserWithTeam: ObserveSelfUserWithTeamUseCase, + private val updateAvailabilityStatus: UpdateSelfAvailabilityStatusUseCase, + private val updateTextStatus: UpdateSelfTextStatusUseCase, + private val dispatchers: DispatcherProvider, +) : ViewModel() { + private val dataStore = userDataStoreProvider.getOrCreate(selfUserId) + private val _statusUpdated = MutableSharedFlow(extraBufferCapacity = 1) + val statusUpdated = _statusUpdated.asSharedFlow() + + var state by mutableStateOf(SelfUserStatusState()) + private set + + init { + observeSelfUser() + } + + fun onEmojiSelected(emoji: String) { + state = state.copy(emoji = emoji) + } + + fun onQuickStatusSelected(emoji: String, message: String) { + if (!state.isTeamMember) return + state = state.copy( + emoji = emoji, + message = message.take(MAX_STATUS_TEXT_LENGTH) + ) + } + + fun dismissStatusDialog() { + state = state.copy(statusDialogData = null) + } + + fun dialogCheckBoxStateChanged(isChecked: Boolean) { + state = state.copy(statusDialogData = state.statusDialogData?.changeCheckBoxState(isChecked)) + } + + fun changeAvailabilityStatusClick(status: UserAvailabilityStatus) { + if (state.availabilityStatus == status) return + viewModelScope.launch { + if (shouldShowStatusRationaleDialog(status)) { + state = state.copy(statusDialogData = status.toDialogData()) + } else { + changeAvailabilityStatus(status) + } + } + } + + fun changeAvailabilityStatus(status: UserAvailabilityStatus) { + setNotShowStatusRationaleAgainIfNeeded(status) + viewModelScope.launch { updateAvailabilityStatus(status) } + dismissStatusDialog() + } + + fun updateCustomStatus(message: String) { + if (!state.isTeamMember) return + val bundledStatus = buildTextStatus(state.emoji, message) + viewModelScope.launch { + state = state.copy(isSaving = true) + updateTextStatus(bundledStatus).fold( + fnL = { _statusUpdated.tryEmit(false) }, + fnR = { _statusUpdated.tryEmit(true) } + ) + state = state.copy(isSaving = false) + } + } + + fun clearCustomStatus() { + if (!state.isTeamMember) return + state = state.copy(emoji = null, message = "") + viewModelScope.launch { + state = state.copy(isSaving = true) + updateTextStatus(" ").fold( + fnL = { _statusUpdated.tryEmit(false) }, + fnR = { _statusUpdated.tryEmit(true) } + ) + state = state.copy(isSaving = false) + } + } + + private fun observeSelfUser() { + viewModelScope.launch { + observeSelfUserWithTeam() + .flowOn(dispatchers.io()) + .collect { (selfUser, team) -> + val parsedStatus = parseTextStatus(selfUser.textStatus) + state = state.copy( + availabilityStatus = selfUser.availabilityStatus, + emoji = parsedStatus.emoji, + message = parsedStatus.message, + savedEmoji = parsedStatus.emoji, + savedMessage = parsedStatus.message, + isTeamMember = team != null, + ) + } + } + } + + private fun setNotShowStatusRationaleAgainIfNeeded(status: UserAvailabilityStatus) { + if (state.statusDialogData?.isCheckBoxChecked == true) { + viewModelScope.launch { dataStore.dontShowStatusRationaleAgain(status) } + } + } + + private suspend fun shouldShowStatusRationaleDialog(status: UserAvailabilityStatus): Boolean = + dataStore.shouldShowStatusRationaleFlow(status).first() + + private fun UserAvailabilityStatus.toDialogData(): StatusDialogData = when (this) { + UserAvailabilityStatus.AVAILABLE -> StatusDialogData.StateAvailable() + UserAvailabilityStatus.BUSY -> StatusDialogData.StateBusy() + UserAvailabilityStatus.AWAY -> StatusDialogData.StateAway() + UserAvailabilityStatus.NONE -> StatusDialogData.StateNone() + } +} + +data class ParsedTextStatus(val emoji: String?, val message: String) + +fun parseTextStatus(textStatus: String?): ParsedTextStatus { + val status = textStatus.orEmpty().trim() + if (status.isBlank()) return ParsedTextStatus(null, "") + + val emojiEnd = emojiClusterEnd(status) + return if (emojiEnd > 0) { + ParsedTextStatus( + emoji = status.substring(0, emojiEnd), + message = status.substring(emojiEnd).trim().take(MAX_STATUS_TEXT_LENGTH) + ) + } else { + ParsedTextStatus(DEFAULT_STATUS_EMOJI, status.take(MAX_STATUS_TEXT_LENGTH)) + } +} + +private fun emojiClusterEnd(value: String): Int { + val firstCodePoint = value.codePointAt(0) + if (!isEmojiLike(firstCodePoint)) return 0 + + var index = Character.charCount(firstCodePoint) + while (index < value.length) { + val codePoint = value.codePointAt(index) + val codePointLength = Character.charCount(codePoint) + when { + codePoint == VARIATION_SELECTOR || isEmojiModifier(codePoint) -> index += codePointLength + codePoint == ZERO_WIDTH_JOINER && index + codePointLength < value.length -> { + val nextCodePoint = value.codePointAt(index + codePointLength) + if (!isEmojiLike(nextCodePoint)) return index + index += codePointLength + Character.charCount(nextCodePoint) + } + else -> return index + } + } + return index +} + +private fun isEmojiLike(codePoint: Int): Boolean = + Character.getType(codePoint) == Character.OTHER_SYMBOL.toInt() || + codePoint in MISC_SYMBOLS_AND_PICTOGRAPHS || + codePoint in EMOTICONS || + codePoint in TRANSPORT_AND_MAP || + codePoint in SUPPLEMENTAL_SYMBOLS_AND_PICTOGRAPHS + +private fun isEmojiModifier(codePoint: Int): Boolean = codePoint in EMOJI_MODIFIERS + +private const val ZERO_WIDTH_JOINER = 0x200D +private const val VARIATION_SELECTOR = 0xFE0F +private val EMOJI_MODIFIERS = 0x1F3FB..0x1F3FF +private val EMOTICONS = 0x1F600..0x1F64F +private val MISC_SYMBOLS_AND_PICTOGRAPHS = 0x1F300..0x1F5FF +private val TRANSPORT_AND_MAP = 0x1F680..0x1F6FF +private val SUPPLEMENTAL_SYMBOLS_AND_PICTOGRAPHS = 0x1F900..0x1F9FF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99b9479c8d..c82b6e1763 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,7 @@ Username, %s Team name, %s change availability status + update status Create a new team or personal account or log in Manage team Go back to your profile overview @@ -713,6 +714,16 @@ Log out Your Other Accounts Availability + Set a status + Update Status + Status updated + Custom Status + Enter custom status message + %1$d/%2$d + Quick Status + In a meeting + Out sick + Out of office New Team or Add Account Manage Team Details diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusViewModelTest.kt new file mode 100644 index 0000000000..9834385582 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/status/SelfUserStatusViewModelTest.kt @@ -0,0 +1,99 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.self.status + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class SelfUserStatusViewModelTest { + + @Test + fun givenTextStatusWithEmoji_whenParsing_thenSplitsEmojiAndMessage() { + val result = parseTextStatus("\uD83C\uDFDD\uFE0F On vacation") + + assertEquals("\uD83C\uDFDD\uFE0F", result.emoji) + assertEquals("On vacation", result.message) + } + + @Test + fun givenTextStatusWithoutEmoji_whenParsing_thenUsesDefaultEmojiAndFullText() { + val result = parseTextStatus("Working remotely") + + assertEquals(DEFAULT_STATUS_EMOJI, result.emoji) + assertEquals("Working remotely", result.message) + } + + @Test + fun givenBlankTextStatus_whenParsing_thenUsesEmptyMessage() { + val result = parseTextStatus(null) + + assertNull(result.emoji) + assertEquals("", result.message) + } + + @Test + fun givenTextWithoutEmoji_whenResolvingEmoji_thenUsesDefaultEmoji() { + val result = resolveStatusEmoji(emoji = null, message = "Working remotely") + + assertEquals(DEFAULT_STATUS_EMOJI, result) + } + + @Test + fun givenSelectedEmoji_whenResolvingEmoji_thenKeepsSelectedEmoji() { + val result = resolveStatusEmoji(emoji = "\uD83C\uDFDD\uFE0F", message = "Working remotely") + + assertEquals("\uD83C\uDFDD\uFE0F", result) + } + + @Test + fun givenBlankMessage_whenResolvingEmoji_thenReturnsNull() { + val result = resolveStatusEmoji(emoji = null, message = " ") + + assertNull(result) + } + + @Test + fun givenTextWithoutEmoji_whenBuildingTextStatus_thenUsesDefaultEmoji() { + val result = buildTextStatus(emoji = null, message = "Working remotely") + + assertEquals("$DEFAULT_STATUS_EMOJI Working remotely", result) + } + + @Test + fun givenSelectedEmoji_whenBuildingTextStatus_thenKeepsSelectedEmoji() { + val result = buildTextStatus(emoji = "\uD83C\uDFDD\uFE0F", message = "Working remotely") + + assertEquals("\uD83C\uDFDD\uFE0F Working remotely", result) + } + + @Test + fun givenSelectedEmojiAndBlankMessage_whenBuildingTextStatus_thenReturnsOnlyEmoji() { + val result = buildTextStatus(emoji = "\uD83C\uDFDD\uFE0F", message = " ") + + assertEquals("\uD83C\uDFDD\uFE0F", result) + } + + @Test + fun givenNoEmojiAndBlankMessage_whenBuildingTextStatus_thenReturnsSingleSpace() { + val result = buildTextStatus(emoji = null, message = " ") + + assertEquals(" ", result) + } +} diff --git a/kalium b/kalium index 7fed478f72..060dbc6595 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 7fed478f72d9f885211a0f088ab40c96cc5670ec +Subproject commit 060dbc659569653ab987fac66cf935b88b9c56c1