diff --git a/app/src/main/kotlin/com/wire/android/navigation/Extras.kt b/app/src/main/kotlin/com/wire/android/navigation/Extras.kt deleted file mode 100644 index b13f530dd62..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/Extras.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 - -const val EXTRA_USER_ID = "extra_user_id" -const val EXTRA_USER_NAME = "extra_user_name" -const val EXTRA_MESSAGE_ID = "extra_message_id" diff --git a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt index eb62eb12fe2..c0af17321c2 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt @@ -35,6 +35,8 @@ import com.wire.android.ui.NavGraphs import com.wire.android.ui.destinations.Destination import com.wire.android.util.CustomTabsHelper import com.wire.kalium.logger.obfuscateId +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json @SuppressLint("RestrictedApi") internal fun NavController.navigateToItem(command: NavigationCommand) { @@ -44,6 +46,7 @@ internal fun NavController.navigateToItem(command: NavigationCommand) { fun lastNestedGraph() = lastDestination()?.takeIf { it.navGraph() != navGraph }?.navGraph() fun firstDestinationWithRoute(route: String) = currentBackStack.value.firstOrNull { it.destination.route?.getBaseRoute() == route.getBaseRoute() } + fun lastDestinationFromOtherGraph(graph: NavGraphSpec) = currentBackStack.value.lastOrNull { it.navGraph() != graph } appLogger.d("[$TAG] -> command: ${command.destination.route.obfuscateId()}") @@ -115,4 +118,19 @@ fun Direction.handleNavigation(context: Context, handleOtherDirection: (Directio else -> handleOtherDirection(this) } +object ArgsSerializer { + @OptIn(ExperimentalSerializationApi::class) + private val instance: Json by lazy { + Json { + encodeDefaults = true + explicitNulls = false + // to enable the serialization of maps with complex keys + // e.g. Map + allowStructuredMapKeys = true + } + } + + operator fun invoke() = instance +} + private const val TAG = "NavigationUtils" diff --git a/app/src/main/kotlin/com/wire/android/ui/common/imagepreview/AvatarPickerFlow.kt b/app/src/main/kotlin/com/wire/android/ui/common/imagepreview/AvatarPickerFlow.kt index 6cc8d954f23..f80c97b2499 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/imagepreview/AvatarPickerFlow.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/imagepreview/AvatarPickerFlow.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.common.imagepreview import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.wire.android.ui.userprofile.avatarpicker.ImageSource @@ -30,7 +31,7 @@ import com.wire.android.util.permission.rememberTakePictureFlow class AvatarPickerFlow( private val takePictureFlow: UseCameraRequestFlow, - private val openGalleryFlow: UseStorageRequestFlow + private val openGalleryFlow: UseStorageRequestFlow ) { fun launch(imageSource: ImageSource) { when (imageSource) { @@ -56,7 +57,8 @@ fun rememberPickPictureState( ) val openGalleryFlow = rememberOpenGalleryFlow( - onGalleryItemPicked = { pickedPictureUri -> onImageSelected(pickedPictureUri) }, + contract = ActivityResultContracts.GetContent(), + onGalleryItemPicked = { pickedPictureUri -> pickedPictureUri?.let { onImageSelected(it) } }, onPermissionDenied = { /* Nothing to do */ }, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/AssetTooLargeDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/AssetTooLargeDialog.kt index 61dcb9f3a98..086a338489c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/AssetTooLargeDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/AssetTooLargeDialog.kt @@ -29,38 +29,55 @@ import com.wire.kalium.logic.data.asset.AttachmentType @Composable fun AssetTooLargeDialog(dialogState: AssetTooLargeDialogState, hideDialog: () -> Unit) { - if (dialogState is AssetTooLargeDialogState.Visible) { - WireDialog( - title = getTitle(dialogState), - text = getLabel(dialogState), - buttonsHorizontalAlignment = false, - onDismiss = hideDialog, - optionButton1Properties = WireDialogButtonProperties( - text = stringResource(R.string.label_ok), - type = WireDialogButtonType.Primary, - onClick = hideDialog + when (dialogState) { + is AssetTooLargeDialogState.Visible -> { + WireDialog( + title = getTitle(dialogState), + text = getLabel(dialogState), + buttonsHorizontalAlignment = false, + onDismiss = hideDialog, + optionButton1Properties = WireDialogButtonProperties( + text = stringResource(R.string.label_ok), + type = WireDialogButtonType.Primary, + onClick = hideDialog + ) ) - ) + } + + AssetTooLargeDialogState.Hidden -> {} } } @Composable -private fun getTitle(dialogState: AssetTooLargeDialogState.Visible) = when (dialogState.assetType) { - AttachmentType.IMAGE -> stringResource(R.string.title_image_could_not_be_sent) - AttachmentType.VIDEO -> stringResource(R.string.title_video_could_not_be_sent) - AttachmentType.AUDIO, // TODO - AttachmentType.GENERIC_FILE -> stringResource(R.string.title_file_could_not_be_sent) +private fun getTitle(dialogState: AssetTooLargeDialogState) = when (dialogState) { + AssetTooLargeDialogState.Hidden -> "" + is AssetTooLargeDialogState.Visible -> + if (dialogState.multipleAssets) { + stringResource(id = R.string.title_assets_could_not_be_sent) + } else { + when (dialogState.assetType) { + AttachmentType.IMAGE -> stringResource(R.string.title_image_could_not_be_sent) + AttachmentType.VIDEO -> stringResource(R.string.title_video_could_not_be_sent) + AttachmentType.AUDIO, // TODO + AttachmentType.GENERIC_FILE -> stringResource(R.string.title_file_could_not_be_sent) + } + } } @Composable -private fun getLabel(dialogState: AssetTooLargeDialogState.Visible) = when (dialogState.assetType) { - AttachmentType.IMAGE -> stringResource(R.string.label_shared_image_too_large, dialogState.maxLimitInMB) - AttachmentType.VIDEO -> stringResource(R.string.label_shared_video_too_large, dialogState.maxLimitInMB) - AttachmentType.AUDIO, // TODO - AttachmentType.GENERIC_FILE -> stringResource(R.string.label_shared_file_too_large, dialogState.maxLimitInMB) -}.let { - if (dialogState.savedToDevice) it + "\n" + stringResource(R.string.label_file_saved_to_device) - else it +private fun getLabel(dialogState: AssetTooLargeDialogState) = when (dialogState) { + AssetTooLargeDialogState.Hidden -> "" + is AssetTooLargeDialogState.Visible -> when (dialogState.assetType) { + AttachmentType.IMAGE -> stringResource(R.string.label_shared_image_too_large, dialogState.maxLimitInMB) + AttachmentType.VIDEO -> stringResource(R.string.label_shared_video_too_large, dialogState.maxLimitInMB) + AttachmentType.AUDIO, // TODO + AttachmentType.GENERIC_FILE -> stringResource(R.string.label_shared_file_too_large, dialogState.maxLimitInMB) + }.let { + var label = it + if (dialogState.multipleAssets) label = label + "\n" + stringResource(R.string.label_shared_multiple_assets_error) + if (dialogState.savedToDevice) label = label + "\n" + stringResource(R.string.label_file_saved_to_device) + label + } } @Preview @@ -68,3 +85,9 @@ private fun getLabel(dialogState: AssetTooLargeDialogState.Visible) = when (dial fun PreviewAssetTooLargeDialog() { AssetTooLargeDialog(AssetTooLargeDialogState.Visible(AttachmentType.VIDEO, 100, true)) {} } + +@Preview +@Composable +fun PreviewMultipleAssetTooLargeDialog() { + AssetTooLargeDialog(AssetTooLargeDialogState.Visible(AttachmentType.VIDEO, 100, false, true)) {} +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavArgs.kt index 2f3f7f65241..764c9fe3a83 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavArgs.kt @@ -17,9 +17,13 @@ */ package com.wire.android.ui.home.conversations +import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.serialization.Serializable +@Serializable data class ConversationNavArgs( val conversationId: ConversationId, - val searchedMessageId: String? = null + val searchedMessageId: String? = null, + val pendingBundles: ArrayList? = null ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 7dea288b51e..3cefb63acd0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.conversations import android.annotation.SuppressLint +import android.content.Context import android.net.Uri import android.text.format.DateUtils import androidx.activity.compose.BackHandler @@ -82,6 +83,7 @@ import com.wire.android.appLogger import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable import com.wire.android.model.SnackBarMessage +import com.wire.android.navigation.ArgsSerializer import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator @@ -127,6 +129,7 @@ import com.wire.android.ui.home.conversations.edit.EditMessageMenuItems import com.wire.android.ui.home.conversations.info.ConversationDetailsData import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel import com.wire.android.ui.home.conversations.info.ConversationInfoViewState +import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBackArgs import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel @@ -141,6 +144,7 @@ import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel import com.wire.android.ui.home.gallery.MediaGalleryActionType import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs import com.wire.android.ui.home.messagecomposer.MessageComposer +import com.wire.android.ui.home.messagecomposer.model.ComposableMessageBundle import com.wire.android.ui.home.messagecomposer.model.MessageBundle import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder @@ -197,6 +201,10 @@ private const val MAX_GROUP_SIZE_FOR_CALL_WITHOUT_ALERT = 5 @Composable fun ConversationScreen( navigator: Navigator, + groupDetailsScreenResultRecipient: ResultRecipient, + mediaGalleryScreenResultRecipient: ResultRecipient, + imagePreviewScreenResultRecipient: ResultRecipient, + resultNavigator: ResultBackNavigator, conversationInfoViewModel: ConversationInfoViewModel = hiltViewModel(), conversationBannerViewModel: ConversationBannerViewModel = hiltViewModel(), conversationCallViewModel: ConversationCallViewModel = hiltViewModel(), @@ -204,10 +212,7 @@ fun ConversationScreen( messageComposerViewModel: MessageComposerViewModel = hiltViewModel(), sendMessageViewModel: SendMessageViewModel = hiltViewModel(), conversationMigrationViewModel: ConversationMigrationViewModel = hiltViewModel(), - messageDraftViewModel: MessageDraftViewModel = hiltViewModel(), - groupDetailsScreenResultRecipient: ResultRecipient, - mediaGalleryScreenResultRecipient: ResultRecipient, - resultNavigator: ResultBackNavigator, + messageDraftViewModel: MessageDraftViewModel = hiltViewModel() ) { val coroutineScope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current @@ -353,7 +358,7 @@ fun ConversationScreen( ConversationScreen( bannerMessage = conversationBannerViewModel.bannerState, - messageComposerViewState = messageComposerViewState, + messageComposerViewState = messageComposerViewState.value, conversationCallViewState = conversationCallViewModel.conversationCallViewState, conversationInfoViewState = conversationInfoViewModel.conversationInfoViewState, conversationMessagesViewState = conversationMessagesViewModel.conversationViewState, @@ -373,13 +378,13 @@ fun ConversationScreen( ) }, onSendMessage = sendMessageViewModel::trySendMessage, - onImagePicked = { + onImagesPicked = { navigator.navigate( NavigationCommand( ImagesPreviewScreenDestination( conversationId = conversationInfoViewModel.conversationInfoViewState.conversationId, conversationName = conversationInfoViewModel.conversationInfoViewState.conversationName.asString(resources), - assetUri = it + assetUriList = ArrayList(it) ) ) ) @@ -451,7 +456,10 @@ fun ConversationScreen( }, composerMessages = sendMessageViewModel.infoMessage, conversationMessages = conversationMessagesViewModel.infoMessage, - conversationMessagesViewModel = conversationMessagesViewModel, + shareAsset = conversationMessagesViewModel::shareAsset, + onDownloadAssetClick = conversationMessagesViewModel::downloadOrFetchAssetAndShowDialog, + onOpenAssetClick = conversationMessagesViewModel::downloadAndOpenAsset, + onNavigateToReplyOriginalMessage = conversationMessagesViewModel::navigateToReplyOriginalMessage, onSelfDeletingMessageRead = messageComposerViewModel::startSelfDeletion, onNewSelfDeletingMessagesStatus = messageComposerViewModel::updateSelfDeletingMessages, tempWritableImageUri = messageComposerViewModel.tempWritableImageUri, @@ -615,6 +623,23 @@ fun ConversationScreen( } } } + + imagePreviewScreenResultRecipient.onNavResult { result -> + when (result) { + Canceled -> {} + is Value -> { + val pendingBundles = ArgsSerializer().decodeFromString(result.value).pendingBundles + sendMessageViewModel.trySendMessages( + pendingBundles.map { assetBundle -> + ComposableMessageBundle.AttachmentPickedBundle( + conversationId = conversationMessagesViewModel.conversationId, + assetBundle = assetBundle + ) + } + ) + } + } + } } private fun conversationScreenOnBackButtonClick( @@ -675,14 +700,14 @@ private fun startCallIfPossible( @Composable private fun ConversationScreen( bannerMessage: UIText?, - messageComposerViewState: MutableState, + messageComposerViewState: MessageComposerViewState, conversationCallViewState: ConversationCallViewState, conversationInfoViewState: ConversationInfoViewState, conversationMessagesViewState: ConversationMessagesViewState, onOpenProfile: (String) -> Unit, onMessageDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onSendMessage: (MessageBundle) -> Unit, - onImagePicked: (Uri) -> Unit, + onImagesPicked: (List) -> Unit, onDeleteMessage: (String, Boolean) -> Unit, onAudioClick: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, @@ -697,7 +722,10 @@ private fun ConversationScreen( onBackButtonClick: () -> Unit, composerMessages: SharedFlow, conversationMessages: SharedFlow, - conversationMessagesViewModel: ConversationMessagesViewModel, + shareAsset: (Context, messageId: String) -> Unit, + onDownloadAssetClick: (messageId: String) -> Unit, + onOpenAssetClick: (messageId: String) -> Unit, + onNavigateToReplyOriginalMessage: (UIMessage) -> Unit, onSelfDeletingMessageRead: (UIMessage) -> Unit, onNewSelfDeletingMessagesStatus: (SelfDeletionTimer) -> Unit, tempWritableImageUri: Uri?, @@ -734,19 +762,19 @@ private fun ConversationScreen( onEditClick = { messageId, messageText, mentions -> messageComposerStateHolder.toEdit(messageId, messageText, mentions) }, onShareAssetClick = { menuType.selectedMessage.header.messageId.let { - conversationMessagesViewModel.shareAsset(context, it) + shareAsset(context, it) conversationScreenState.hideContextMenu() } }, - onDownloadAssetClick = conversationMessagesViewModel::downloadOrFetchAssetAndShowDialog, - onOpenAssetClick = conversationMessagesViewModel::downloadAndOpenAsset + onDownloadAssetClick = onDownloadAssetClick, + onOpenAssetClick = onOpenAssetClick ) } is ConversationScreenState.BottomSheetMenuType.SelfDeletion -> { SelfDeletionMenuItems( hideEditMessageMenu = conversationScreenState::hideContextMenu, - currentlySelected = messageComposerViewState.value.selfDeletionTimer.duration.toSelfDeletionDuration(), + currentlySelected = messageComposerViewState.selfDeletionTimer.duration.toSelfDeletionDuration(), onSelfDeletionDurationChanged = { newTimer -> onNewSelfDeletingMessagesStatus(SelfDeletionTimer.Enabled(newTimer.value)) } @@ -769,7 +797,7 @@ private fun ConversationScreen( hasOngoingCall = conversationCallViewState.hasOngoingCall, onJoinCallButtonClick = onJoinCall, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, - isInteractionEnabled = messageComposerViewState.value.interactionAvailability == InteractionAvailability.ENABLED + isInteractionEnabled = messageComposerViewState.interactionAvailability == InteractionAvailability.ENABLED ) ConversationBanner(bannerMessage) } @@ -799,7 +827,7 @@ private fun ConversationScreen( messageComposerStateHolder = messageComposerStateHolder, messages = conversationMessagesViewState.messages, onSendMessage = onSendMessage, - onImagePicked = onImagePicked, + onImagesPicked = onImagesPicked, onAssetItemClicked = onAssetItemClicked, onAudioItemClicked = onAudioClick, onChangeAudioPosition = onChangeAudioPosition, @@ -821,7 +849,7 @@ private fun ConversationScreen( tempWritableVideoUri = tempWritableVideoUri, onLinkClick = onLinkClick, onTypingEvent = onTypingEvent, - onNavigateToReplyOriginalMessage = conversationMessagesViewModel::navigateToReplyOriginalMessage, + onNavigateToReplyOriginalMessage = onNavigateToReplyOriginalMessage, currentTimeInMillisFlow = currentTimeInMillisFlow ) } @@ -848,7 +876,7 @@ private fun ConversationScreenContent( messageComposerStateHolder: MessageComposerStateHolder, messages: Flow>, onSendMessage: (MessageBundle) -> Unit, - onImagePicked: (Uri) -> Unit, + onImagesPicked: (List) -> Unit, onAssetItemClicked: (String) -> Unit, onAudioItemClicked: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, @@ -919,7 +947,7 @@ private fun ConversationScreenContent( tempWritableVideoUri = tempWritableVideoUri, tempWritableImageUri = tempWritableImageUri, onTypingEvent = onTypingEvent, - onImagePicked = onImagePicked + onImagesPicked = onImagesPicked ) } @@ -983,6 +1011,7 @@ fun MessageList( selectedMessageId: String?, onNavigateToReplyOriginalMessage: (UIMessage) -> Unit, interactionAvailability: InteractionAvailability, + modifier: Modifier = Modifier, currentTimeInMillisFlow: Flow = flow { } ) { val prevItemCount = remember { mutableStateOf(lazyPagingMessages.itemCount) } @@ -1024,7 +1053,7 @@ fun MessageList( Box( contentAlignment = Alignment.BottomEnd, - modifier = Modifier + modifier = modifier .fillMaxSize() .background(color = colorsScheme().backgroundVariant), content = { @@ -1227,10 +1256,12 @@ private fun updateLastReadMessage( @Composable fun JumpToLastMessageButton( - coroutineScope: CoroutineScope = rememberCoroutineScope(), - lazyListState: LazyListState + lazyListState: LazyListState, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope = rememberCoroutineScope() ) { AnimatedVisibility( + modifier = modifier, visible = lazyListState.firstVisibleItemIndex > 0, enter = expandIn { it }, exit = shrinkOut { it } @@ -1279,7 +1310,7 @@ fun PreviewConversationScreen() { ) ConversationScreen( bannerMessage = null, - messageComposerViewState = messageComposerViewState, + messageComposerViewState = messageComposerViewState.value, conversationCallViewState = ConversationCallViewState(), conversationInfoViewState = ConversationInfoViewState( conversationId = conversationId, @@ -1303,7 +1334,10 @@ fun PreviewConversationScreen() { onBackButtonClick = {}, composerMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), conversationMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), - conversationMessagesViewModel = hiltViewModel(), + shareAsset = { _, _ -> }, + onOpenAssetClick = {}, + onDownloadAssetClick = {}, + onNavigateToReplyOriginalMessage = {}, onSelfDeletingMessageRead = {}, onNewSelfDeletingMessagesStatus = {}, tempWritableImageUri = null, @@ -1316,6 +1350,6 @@ fun PreviewConversationScreen() { messageComposerStateHolder = messageComposerStateHolder, onLinkClick = { _ -> }, onTypingEvent = {}, - onImagePicked = {} + onImagesPicked = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewState.kt index 6de10ef75c8..91c052fb95c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewState.kt @@ -36,7 +36,12 @@ data class MessageComposerViewState( sealed class AssetTooLargeDialogState { data object Hidden : AssetTooLargeDialogState() - data class Visible(val assetType: AttachmentType, val maxLimitInMB: Int, val savedToDevice: Boolean) : AssetTooLargeDialogState() + data class Visible( + val assetType: AttachmentType, + val maxLimitInMB: Int, + val savedToDevice: Boolean, + val multipleAssets: Boolean = false + ) : AssetTooLargeDialogState() } sealed class VisitLinkDialogState { @@ -52,15 +57,21 @@ sealed class InvalidLinkDialogState { sealed class SureAboutMessagingDialogState { data object Hidden : SureAboutMessagingDialogState() sealed class Visible(open val conversationId: ConversationId) : SureAboutMessagingDialogState() { - data class ConversationVerificationDegraded(val messageBundleToSend: MessageBundle) : Visible(messageBundleToSend.conversationId) + data class ConversationVerificationDegraded( + override val conversationId: ConversationId, + val messageBundleListToSend: List + ) : Visible(conversationId) sealed class ConversationUnderLegalHold(override val conversationId: ConversationId) : Visible(conversationId) { data class BeforeSending( - val messageBundleToSend: MessageBundle - ) : ConversationUnderLegalHold(messageBundleToSend.conversationId) + override val conversationId: ConversationId, + val messageBundleListToSend: List + ) : ConversationUnderLegalHold(conversationId) - data class AfterSending(val messageId: MessageId, override val conversationId: ConversationId) : - ConversationUnderLegalHold(conversationId) + data class AfterSending( + override val conversationId: ConversationId, + val messageIdList: List + ) : ConversationUnderLegalHold(conversationId) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/CheckAssetRestrictionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/CheckAssetRestrictionsViewModel.kt new file mode 100644 index 00000000000..a8445d77da1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/CheckAssetRestrictionsViewModel.kt @@ -0,0 +1,52 @@ +/* + * 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.media + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.wire.android.ui.home.conversations.AssetTooLargeDialogState +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.ui.sharing.ImportedMediaAsset +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CheckAssetRestrictionsViewModel @Inject constructor() : ViewModel() { + + var assetTooLargeDialogState: AssetTooLargeDialogState by mutableStateOf( + AssetTooLargeDialogState.Hidden + ) + private set + + fun checkRestrictions(importedMediaList: List, onSuccess: (bundleList: List) -> Unit) { + importedMediaList.firstOrNull { it.assetSizeExceeded != null }?.let { + assetTooLargeDialogState = AssetTooLargeDialogState.Visible( + assetType = it.assetBundle.assetType, + maxLimitInMB = it.assetSizeExceeded!!, + savedToDevice = false, + multipleAssets = true + ) + } ?: onSuccess(importedMediaList.map { it.assetBundle }) + } + + fun hideDialog() { + assetTooLargeDialogState = AssetTooLargeDialogState.Hidden + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/AssetFilePreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/AssetFilePreview.kt new file mode 100644 index 00000000000..7ca00fa8f86 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/AssetFilePreview.kt @@ -0,0 +1,104 @@ +/* + * 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.media.preview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.wire.android.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.DeviceUtil +import java.util.Locale + +@Composable +fun AssetFilePreview(assetName: String, sizeInBytes: Long, modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + .fillMaxSize() + .padding(horizontal = dimensions().spacing32x) + ) { + Box(contentAlignment = Alignment.BottomCenter) { + Icon( + modifier = Modifier.size(dimensions().spacing80x), + painter = painterResource(id = R.drawable.ic_file), + contentDescription = assetName, + tint = MaterialTheme.wireColorScheme.secondaryText + ) + Text( + modifier = Modifier.padding(bottom = dimensions().spacing8x), + text = assetName.split(".").last().uppercase(Locale.getDefault()), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.wireTypography.title05 + ) + } + VerticalSpace.x16() + Text( + assetName, + style = MaterialTheme.wireTypography.title02, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + VerticalSpace.x8() + Text( + DeviceUtil.formatSize(sizeInBytes), + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.secondaryText + ) + } +} + +@MultipleThemePreviews +@Composable +fun PreviewAssetFilePreview(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .width(400.dp) + .height(800.dp) + ) { + WireTheme { + AssetFilePreview( + assetName = "very long file naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaame.png", + sizeInBytes = 1500 + ) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/AssetTilePreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/AssetTilePreview.kt new file mode 100644 index 00000000000..76f1775a7bf --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/AssetTilePreview.kt @@ -0,0 +1,183 @@ +/* + * 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.media.preview + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.wire.android.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.image.WireImage +import com.wire.android.ui.common.spacers.HorizontalSpace +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.asset.AttachmentType +import okio.Path.Companion.toPath +import java.util.Locale + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AssetTilePreview( + assetBundle: AssetBundle, + showOnlyExtension: Boolean, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + onClick: () -> Unit +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(shape = RoundedCornerShape(dimensions().messageAssetBorderRadius)) + .background( + color = MaterialTheme.wireColorScheme.onPrimary, + shape = RoundedCornerShape(dimensions().messageAssetBorderRadius) + ) + .border( + width = if (isSelected) { + dimensions().spacing2x + } else { + dimensions().spacing1x + }, + color = if (isSelected) { + MaterialTheme.wireColorScheme.primary + } else { + MaterialTheme.wireColorScheme.secondaryButtonDisabledOutline + }, + shape = RoundedCornerShape(dimensions().messageAssetBorderRadius) + ) + .combinedClickable(onClick = onClick) + ) { + when (assetBundle.assetType) { + AttachmentType.IMAGE -> WireImage( + modifier = Modifier.fillMaxSize(), + model = assetBundle.dataPath.toFile(), + contentScale = ContentScale.Crop, + contentDescription = assetBundle.fileName + ) + + AttachmentType.GENERIC_FILE, + AttachmentType.AUDIO, + AttachmentType.VIDEO -> if (showOnlyExtension) { + AssetExtensionPreviewTile(assetBundle.assetName) + } else { + AssetFilePreviewTile(assetBundle, Modifier.fillMaxSize()) + } + } + } +} + +@Composable +fun AssetExtensionPreviewTile(assetName: String, modifier: Modifier = Modifier) { + Text( + text = assetName.split(".").last().uppercase(Locale.getDefault()), + style = MaterialTheme.wireTypography.title05, + color = MaterialTheme.wireColorScheme.secondaryText, + modifier = modifier + ) +} + +@Composable +fun AssetFilePreviewTile(assetBundle: AssetBundle, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(dimensions().spacing8x)) { + Text( + modifier = Modifier.weight(1F), + text = assetBundle.assetName, + style = MaterialTheme.wireTypography.body02, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + Row { + Image( + modifier = Modifier, + painter = painterResource(R.drawable.ic_file), + contentDescription = stringResource(R.string.content_description_image_message), + colorFilter = ColorFilter.tint(MaterialTheme.wireColorScheme.badge) + ) + HorizontalSpace.x4() + Text( + text = assetBundle.extensionWithSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.wireColorScheme.secondaryText, + style = MaterialTheme.wireTypography.subline01 + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewAssetTileItemPreview() { + WireTheme { + AssetFilePreviewTile( + AssetBundle( + "key", + "file/pdf", + dataPath = "some-data-path".toPath(), + 20_000, + "long naaaaaaaaaaaaaaaaaaaaaaaaaaaaame document.pdf", + AttachmentType.GENERIC_FILE + ), + modifier = Modifier + .height(dimensions().spacing120x) + .width(dimensions().spacing120x) + ) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewAssetTileFullWidthPreview() { + WireTheme { + AssetFilePreviewTile( + AssetBundle( + "key", + "file/pdf", + dataPath = "some-data-path".toPath(), + 20_000, + "long naaaaaaaaaaaaaaaaaaaaaaaaaaaaame document.pdf", + AttachmentType.GENERIC_FILE + ), + modifier = Modifier.height(dimensions().spacing120x) + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewNavArgs.kt index 9bc17cfa753..c09555c9b4e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewNavArgs.kt @@ -18,10 +18,15 @@ package com.wire.android.ui.home.conversations.media.preview import android.net.Uri +import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.serialization.Serializable data class ImagesPreviewNavArgs( val conversationId: ConversationId, val conversationName: String, - val assetUri: Uri + val assetUriList: ArrayList ) + +@Serializable +data class ImagesPreviewNavBackArgs(val pendingBundles: List) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt index 3088fb2234c..b34196cef0d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt @@ -17,58 +17,69 @@ */ package com.wire.android.ui.home.conversations.media.preview +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import coil.compose.AsyncImage -import coil.request.ImageRequest import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R -import com.wire.android.model.SnackBarMessage +import com.wire.android.navigation.ArgsSerializer import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme -import com.wire.android.ui.common.dialogs.SureAboutMessagingInDegradedConversationDialog import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.divider.WireDivider +import com.wire.android.ui.common.error.ErrorIcon +import com.wire.android.ui.common.image.WireImage +import com.wire.android.ui.common.progress.WireCircularProgressIndicator +import com.wire.android.ui.common.remove.RemoveIcon import com.wire.android.ui.common.scaffold.WireScaffold -import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.home.conversations.AssetTooLargeDialog -import com.wire.android.ui.home.conversations.SureAboutMessagingDialogState -import com.wire.android.ui.home.conversations.model.UriAsset -import com.wire.android.ui.home.conversations.sendmessage.SendMessageState -import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel -import com.wire.android.ui.home.messagecomposer.model.ComposableMessageBundle -import com.wire.android.ui.home.messagecomposer.model.MessageBundle -import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectMessageDialog +import com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.ui.sharing.ImportedMediaAsset +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme -import kotlinx.coroutines.flow.SharedFlow +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.asset.AttachmentType +import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch +import okio.Path.Companion.toPath @RootNavGraph @Destination( @@ -78,51 +89,62 @@ import kotlinx.coroutines.flow.SharedFlow @Composable fun ImagesPreviewScreen( navigator: Navigator, + resultNavigator: ResultBackNavigator, imagesPreviewViewModel: ImagesPreviewViewModel = hiltViewModel(), - sendMessageViewModel: SendMessageViewModel = hiltViewModel() + checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = hiltViewModel() ) { - LaunchedEffect(sendMessageViewModel.viewState.messageSent) { - if (sendMessageViewModel.viewState.messageSent) { - navigator.navigateBack() - } - } - Content( previewState = imagesPreviewViewModel.viewState, - sendState = sendMessageViewModel.viewState, onNavigationPressed = { navigator.navigateBack() }, - onSendMessage = sendMessageViewModel::trySendMessage + onSendMessages = { mediaAssets -> + checkAssetRestrictionsViewModel.checkRestrictions( + importedMediaList = mediaAssets, + onSuccess = { + val result = ArgsSerializer().encodeToString( + serializer = ImagesPreviewNavBackArgs.serializer(), + value = ImagesPreviewNavBackArgs(pendingBundles = ArrayList(it)) + ) + resultNavigator.setResult(result) + resultNavigator.navigateBack() + } + ) + }, + onSelected = imagesPreviewViewModel::onSelected, + onRemoveAsset = imagesPreviewViewModel::onRemove ) AssetTooLargeDialog( - dialogState = sendMessageViewModel.assetTooLargeDialogState, - hideDialog = sendMessageViewModel::hideAssetTooLargeError - ) - - SureAboutMessagingInDegradedConversationDialog( - dialogState = sendMessageViewModel.sureAboutMessagingDialogState, - sendAnyway = sendMessageViewModel::acceptSureAboutSendingMessage, - hideDialog = sendMessageViewModel::dismissSureAboutSendingMessage + dialogState = checkAssetRestrictionsViewModel.assetTooLargeDialogState, + hideDialog = checkAssetRestrictionsViewModel::hideDialog ) - - (sendMessageViewModel.sureAboutMessagingDialogState as? SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold)?.let { - LegalHoldSubjectMessageDialog( - dialogDismissed = sendMessageViewModel::dismissSureAboutSendingMessage, - sendAnywayClicked = sendMessageViewModel::acceptSureAboutSendingMessage, - ) - } - - SnackBarMessage(sendMessageViewModel.infoMessage) } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun Content( previewState: ImagesPreviewState, - sendState: SendMessageState, + onSendMessages: (List) -> Unit, + onSelected: (index: Int) -> Unit, onNavigationPressed: () -> Unit = {}, - onSendMessage: (MessageBundle) -> Unit + onRemoveAsset: (index: Int) -> Unit ) { val configuration = LocalConfiguration.current + val pagerState = rememberPagerState(pageCount = { previewState.assetBundleList.size }) + val scope = rememberCoroutineScope() + LaunchedEffect(key1 = previewState.selectedIndex) { + if (previewState.selectedIndex != pagerState.settledPage) { + scope.launch { + pagerState.animateScrollToPage(previewState.selectedIndex) + } + } + } + + LaunchedEffect(key1 = pagerState.settledPage) { + if (previewState.selectedIndex != pagerState.settledPage) { + onSelected(pagerState.settledPage) + } + } + WireScaffold( topBar = { WireCenterAlignedTopAppBar( @@ -152,7 +174,6 @@ private fun Content( ) HorizontalSpace.x16() WirePrimaryButton( - loading = sendState.inProgress, modifier = Modifier.weight(1F), text = stringResource(id = R.string.import_media_send_button_title), leadingIcon = { @@ -164,12 +185,7 @@ private fun Content( ) }, onClick = { - onSendMessage( - ComposableMessageBundle.AttachmentPickedBundle( - previewState.conversationId, - UriAsset(previewState.assetUri) - ) - ) + onSendMessages(previewState.assetBundleList) } ) HorizontalSpace.x16() @@ -178,34 +194,155 @@ private fun Content( } ) { padding -> Box( - contentAlignment = Alignment.Center, modifier = Modifier .padding(padding) .fillMaxHeight() .fillMaxWidth() ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(previewState.assetUri) - .build(), - contentDescription = "preview_asset_image", - contentScale = ContentScale.FillWidth, - modifier = Modifier.width(configuration.screenWidthDp.dp) - ) + CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .width(configuration.screenWidthDp.dp) + .fillMaxHeight(), + ) { index -> + val assetBundle = previewState.assetBundleList[index].assetBundle + + when (assetBundle.assetType) { + AttachmentType.IMAGE -> WireImage( + modifier = Modifier + .width(configuration.screenWidthDp.dp) + .fillMaxHeight(), + model = previewState.assetBundleList[index].assetBundle.dataPath.toFile(), + contentDescription = previewState.assetBundleList[index].assetBundle.fileName + ) + + AttachmentType.GENERIC_FILE, + AttachmentType.AUDIO, + AttachmentType.VIDEO -> AssetFilePreview( + assetName = assetBundle.fileName, + sizeInBytes = assetBundle.dataSize + ) + } + } + } + + if (previewState.isLoading) { + WireCircularProgressIndicator( + progressColor = MaterialTheme.wireColorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + size = dimensions().spacing24x + ) + } + + LazyRow( + modifier = Modifier + .padding(bottom = dimensions().spacing8x) + .height(dimensions().spacing80x) + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x), + contentPadding = PaddingValues(start = dimensions().spacing16x, end = dimensions().spacing16x) + ) { + items( + count = previewState.assetBundleList.size, + ) { index -> + Box( + modifier = Modifier + .width(dimensions().spacing80x) + .fillMaxHeight() + ) { + AssetTilePreview( + modifier = Modifier + .size(dimensions().spacing64x) + .align(Alignment.Center), + assetBundle = previewState.assetBundleList[index].assetBundle, + isSelected = previewState.selectedIndex == index, + showOnlyExtension = true, + onClick = { onSelected(index) } + ) + + if (previewState.assetBundleList.size > 1) { + RemoveIcon( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { + onRemoveAsset(index) + }, + contentDescription = stringResource(id = R.string.remove_asset_description) + ) + } + if (previewState.assetBundleList[index].assetSizeExceeded != null) { + ErrorIcon( + stringResource(id = R.string.asset_attention_description), + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } } } } +@PreviewMultipleThemes @Composable -private fun SnackBarMessage(infoMessages: SharedFlow) { - val context = LocalContext.current - val snackbarHostState = LocalSnackbarHostState.current - - LaunchedEffect(Unit) { - infoMessages.collect { - snackbarHostState.showSnackbar( - message = it.uiText.asString(context.resources) - ) - } +fun PreviewImagesPreviewScreen() { + WireTheme { + Content( + previewState = ImagesPreviewState( + ConversationId("value", "domain"), + selectedIndex = 0, + conversationName = "Conversation", + assetBundleList = persistentListOf( + ImportedMediaAsset( + AssetBundle( + "key", + "image/png", + "".toPath(), + 20, + "preview.png", + assetType = AttachmentType.IMAGE + ), + assetSizeExceeded = null + ), + ImportedMediaAsset( + AssetBundle( + "key1", + "video/mp4", + "".toPath(), + 20, + "preview.mp4", + assetType = AttachmentType.VIDEO + ), + assetSizeExceeded = null + ), + ImportedMediaAsset( + AssetBundle( + "key2", + "audio/mp3", + "".toPath(), + 20, + "preview.mp3", + assetType = AttachmentType.AUDIO + ), + assetSizeExceeded = 20 + ), + ImportedMediaAsset( + AssetBundle( + "key3", + "document/pdf", + "".toPath(), + 20, + "preview.pdf", + assetType = AttachmentType.GENERIC_FILE + ), + assetSizeExceeded = null + ) + ), + ), + onNavigationPressed = {}, + onSendMessages = {}, + onSelected = {}, + onRemoveAsset = {} + ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewState.kt index 6ca3b29ac6c..161e12f11bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewState.kt @@ -17,7 +17,15 @@ */ package com.wire.android.ui.home.conversations.media.preview -import android.net.Uri +import com.wire.android.ui.sharing.ImportedMediaAsset import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf -data class ImagesPreviewState(val conversationId: ConversationId, val conversationName: String, val assetUri: Uri) +data class ImagesPreviewState( + val conversationId: ConversationId, + val conversationName: String, + val assetBundleList: PersistentList = persistentListOf(), + val selectedIndex: Int = 0, + val isLoading: Boolean = false +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt index ef1f3c8e228..08fa6c10ec1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt @@ -17,25 +17,68 @@ */ package com.wire.android.ui.home.conversations.media.preview +import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import com.wire.android.navigation.SavedStateViewModel +import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase import com.wire.android.ui.navArgs +import com.wire.android.ui.sharing.ImportedMediaAsset +import com.wire.android.util.dispatchers.DispatcherProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject +@HiltViewModel class ImagesPreviewViewModel @Inject constructor( override val savedStateHandle: SavedStateHandle, + private val handleUriAsset: HandleUriAssetUseCase, + private val dispatchers: DispatcherProvider ) : SavedStateViewModel(savedStateHandle) { private val navArgs: ImagesPreviewNavArgs = savedStateHandle.navArgs() var viewState by mutableStateOf( ImagesPreviewState( conversationId = navArgs.conversationId, - conversationName = navArgs.conversationName, - assetUri = navArgs.assetUri + conversationName = navArgs.conversationName ) ) private set + + init { + handleAssets() + } + + fun onSelected(index: Int) { + viewState = viewState.copy(selectedIndex = index) + } + + fun onRemove(index: Int) { + viewState = viewState.copy(assetBundleList = viewState.assetBundleList.removeAt(index)) + } + + private fun handleAssets() { + viewState = viewState.copy(isLoading = true) + viewModelScope.launch { + val assets = navArgs.assetUriList.map { handleImportedAsset(it) } + viewState = viewState.copy( + assetBundleList = assets.filterNotNull().toPersistentList(), + isLoading = false + ) + } + } + + private suspend fun handleImportedAsset(uri: Uri): ImportedMediaAsset? = withContext(dispatchers.io()) { + when (val result = handleUriAsset.invoke(uri, saveToDeviceIfInvalid = false)) { + is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> ImportedMediaAsset(result.assetBundle, result.maxLimitInMB) + + HandleUriAssetUseCase.Result.Failure.Unknown -> null + is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) + } + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageTypesPreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageTypesPreview.kt index 646a14e002f..d9921f68990 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageTypesPreview.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageTypesPreview.kt @@ -38,10 +38,10 @@ import com.wire.android.ui.home.conversations.mock.mockedImageUIMessage import com.wire.android.ui.home.conversations.model.ExpirationStatus import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.MessageFlowStatus -import com.wire.android.ui.home.conversations.model.MessageGenericAsset import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.home.conversations.model.messagetypes.asset.MessageAsset import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText @@ -266,30 +266,12 @@ fun PreviewAssetMessageWithReactions() { @Composable fun PreviewImportedMediaAssetMessageContent() { WireTheme { - MessageGenericAsset( + MessageAsset( assetName = "Some test cool long but very cool long but very asjkl cool long but very long message", assetExtension = "rar.tgz", assetSizeInBytes = 99201224L, onAssetClick = Clickable(enabled = false), - assetTransferStatus = AssetTransferStatus.NOT_DOWNLOADED, - shouldFillMaxWidth = false, - isImportedMediaAsset = true - ) - } -} - -@PreviewMultipleThemes -@Composable -fun PreviewWideImportedAssetMessageContent() { - WireTheme { - MessageGenericAsset( - assetName = "Some test cool long but very cool long but very asjkl cool long but very long message", - assetExtension = "rar.tgz", - assetSizeInBytes = 99201224L, - onAssetClick = Clickable(enabled = false), - assetTransferStatus = AssetTransferStatus.NOT_DOWNLOADED, - shouldFillMaxWidth = true, - isImportedMediaAsset = true + assetTransferStatus = AssetTransferStatus.NOT_DOWNLOADED ) } } @@ -298,14 +280,12 @@ fun PreviewWideImportedAssetMessageContent() { @Composable fun PreviewLoadingAssetMessage() { WireTheme { - MessageGenericAsset( + MessageAsset( assetName = "Some test cool long but very cool long but very asjkl cool long but very long message", assetExtension = "rar.tgz", assetSizeInBytes = 99201224L, onAssetClick = Clickable(enabled = false), - assetTransferStatus = AssetTransferStatus.DOWNLOAD_IN_PROGRESS, - shouldFillMaxWidth = true, - isImportedMediaAsset = false + assetTransferStatus = AssetTransferStatus.DOWNLOAD_IN_PROGRESS ) } } @@ -314,14 +294,12 @@ fun PreviewLoadingAssetMessage() { @Composable fun PreviewFailedDownloadAssetMessage() { WireTheme { - MessageGenericAsset( + MessageAsset( assetName = "Some test cool long but very cool long but very asjkl cool long but very long message", assetExtension = "rar.tgz", assetSizeInBytes = 99201224L, onAssetClick = Clickable(enabled = false), - assetTransferStatus = AssetTransferStatus.FAILED_DOWNLOAD, - shouldFillMaxWidth = true, - isImportedMediaAsset = false + assetTransferStatus = AssetTransferStatus.FAILED_DOWNLOAD ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index f2c731f0e43..2de8dfbe9ab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -87,7 +87,6 @@ import com.wire.android.ui.home.conversations.model.DeliveryStatusContent import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.MessageFlowStatus import com.wire.android.ui.home.conversations.model.MessageFooter -import com.wire.android.ui.home.conversations.model.MessageGenericAsset import com.wire.android.ui.home.conversations.model.MessageHeader import com.wire.android.ui.home.conversations.model.MessageImage import com.wire.android.ui.home.conversations.model.MessageSource @@ -95,6 +94,7 @@ import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.home.conversations.model.messagetypes.asset.MessageAsset import com.wire.android.ui.home.conversations.model.messagetypes.asset.RestrictedAssetMessage import com.wire.android.ui.home.conversations.model.messagetypes.asset.RestrictedGenericFileMessage import com.wire.android.ui.home.conversations.model.messagetypes.audio.AudioMessage @@ -663,7 +663,7 @@ private fun MessageContent( is UIMessageContent.AssetMessage -> { Column { - MessageGenericAsset( + MessageAsset( assetName = messageContent.assetName, assetExtension = messageContent.assetExtension, assetSizeInBytes = messageContent.assetSizeInBytes, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt index ab9e502496b..b6ee61b96d7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt @@ -19,20 +19,49 @@ package com.wire.android.ui.home.conversations.model import android.net.Uri +import androidx.compose.runtime.Stable import com.wire.kalium.logic.data.asset.AttachmentType +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import okio.Path +import okio.Path.Companion.toPath +import kotlin.math.roundToInt /** * Represents a set of metadata information of an asset message */ +@Serializable data class AssetBundle( val key: String, val mimeType: String, + @Serializable(with = PathAsStringSerializer::class) val dataPath: Path, val dataSize: Long, val fileName: String, val assetType: AttachmentType -) +) { + + @Stable + val extensionWithSize: String + get() { + val assetExtension = fileName.split(".").last() + val oneKB = 1024L + val oneMB = oneKB * oneKB + return when { + dataSize < oneKB -> "${assetExtension.uppercase()} ($dataSize B)" + dataSize in oneKB..oneMB -> "${assetExtension.uppercase()} (${dataSize / oneKB} KB)" + else -> "${assetExtension.uppercase()} (${((dataSize / oneMB) * 100.0).roundToInt() / 100.0} MB)" // 2 decimals round off + } + } + + val assetName: String + get() = fileName.split(".").first() +} /** * @param uri Uri of the asset @@ -42,3 +71,15 @@ data class UriAsset( val uri: Uri, val saveToDeviceIfInvalid: Boolean = false ) + +private object PathAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Path", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Path) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Path { + return decoder.decodeString().toPath() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index 07bc1be625c..c57400b929a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -46,13 +46,11 @@ import com.wire.android.ui.common.clickable import com.wire.android.ui.common.dimensions import com.wire.android.ui.home.conversations.CompositeMessageViewModel import com.wire.android.ui.home.conversations.CompositeMessageViewModelImpl -import com.wire.android.ui.home.conversations.model.messagetypes.asset.MessageAsset import com.wire.android.ui.home.conversations.model.messagetypes.image.AsyncImageMessage import com.wire.android.ui.home.conversations.model.messagetypes.image.DisplayableImageMessage import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMessageFailed import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMessageInProgress import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMessageParams -import com.wire.android.ui.home.conversations.model.messagetypes.image.ImportedImageMessage import com.wire.android.ui.markdown.DisplayMention import com.wire.android.ui.markdown.MarkdownConstants.MENTION_MARK import com.wire.android.ui.markdown.MarkdownDocument @@ -169,11 +167,10 @@ fun MessageButtonsContent( @OptIn(ExperimentalFoundationApi::class) @Composable fun MessageImage( - asset: ImageAsset?, + asset: ImageAsset.Remote?, imgParams: ImageMessageParams, transferStatus: AssetTransferStatus, - onImageClick: Clickable, - shouldFillMaxWidth: Boolean = false, + onImageClick: Clickable ) { Box( Modifier @@ -212,11 +209,7 @@ fun MessageImage( ) } - asset != null -> when (asset) { - is ImageAsset.Local -> ImportedImageMessage(asset, shouldFillMaxWidth) - is ImageAsset.Remote -> DisplayableImageMessage(asset, imgParams.normalizedWidth, imgParams.normalizedHeight) - } - + asset != null -> DisplayableImageMessage(asset, imgParams.normalizedWidth, imgParams.normalizedHeight) // Show error placeholder transferStatus == FAILED_UPLOAD || transferStatus == FAILED_DOWNLOAD -> { ImageMessageFailed( @@ -291,27 +284,6 @@ fun MediaAssetImage( } } -@Composable -internal fun MessageGenericAsset( - assetName: String, - assetExtension: String, - assetSizeInBytes: Long, - onAssetClick: Clickable, - assetTransferStatus: AssetTransferStatus, - shouldFillMaxWidth: Boolean = true, - isImportedMediaAsset: Boolean = false -) { - MessageAsset( - assetName, - assetExtension, - assetSizeInBytes, - onAssetClick, - assetTransferStatus, - shouldFillMaxWidth, - isImportedMediaAsset - ) -} - /** * Maps all mentions to DisplayMention in order to find them easier after converting * to markdown document as positions changes due to markdown characters. diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/asset/AssetMessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/asset/AssetMessageTypes.kt index 058634c7d7f..c38fd904f57 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/asset/AssetMessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/asset/AssetMessageTypes.kt @@ -27,8 +27,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -58,10 +56,10 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator 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.util.DeviceUtil import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.isFailed import com.wire.kalium.logic.data.asset.isInProgress -import kotlin.math.roundToInt @Composable internal fun MessageAsset( @@ -69,9 +67,7 @@ internal fun MessageAsset( assetExtension: String, assetSizeInBytes: Long, onAssetClick: Clickable, - assetTransferStatus: AssetTransferStatus, - shouldFillMaxWidth: Boolean, - isImportedMediaAsset: Boolean + assetTransferStatus: AssetTransferStatus ) { val assetDescription = provideAssetDescription(assetExtension, assetSizeInBytes) Box( @@ -91,25 +87,21 @@ internal fun MessageAsset( if (assetTransferStatus == AssetTransferStatus.UPLOAD_IN_PROGRESS) { UploadInProgressAssetMessage() } else { - val assetModifier = if (shouldFillMaxWidth) Modifier + val assetModifier = Modifier .align(Alignment.Center) .fillMaxWidth() - else Modifier - .size(dimensions().importedMediaAssetSize) - .align(Alignment.Center) - .fillMaxSize() Column(modifier = assetModifier.padding(dimensions().spacing8x)) { Text( text = assetName, style = MaterialTheme.wireTypography.body02, fontSize = 15.sp, - maxLines = if (shouldFillMaxWidth) 2 else 4, + maxLines = 2, overflow = TextOverflow.Ellipsis ) val descriptionModifier = Modifier .padding(top = dimensions().spacing8x) .fillMaxWidth() - ConstraintLayout(modifier = (if (!shouldFillMaxWidth) descriptionModifier.fillMaxHeight() else descriptionModifier)) { + ConstraintLayout(modifier = descriptionModifier) { val (icon, description, downloadStatus) = createRefs() Image( modifier = Modifier @@ -137,26 +129,24 @@ internal fun MessageAsset( fontSize = 12.sp, style = MaterialTheme.wireTypography.subline01 ) - if (!isImportedMediaAsset) { - Row( - modifier = Modifier - .wrapContentWidth() - .constrainAs(downloadStatus) { - top.linkTo(parent.top) - end.linkTo(parent.end) - bottom.linkTo(parent.bottom) - } - ) { - Text( - modifier = Modifier.padding(end = dimensions().spacing4x), - text = getDownloadStatusText(assetTransferStatus), - color = MaterialTheme.wireColorScheme.run { - if (assetTransferStatus.isFailed()) error else secondaryText - }, - style = MaterialTheme.wireTypography.subline01 - ) - DownloadStatusIcon(assetTransferStatus) - } + Row( + modifier = Modifier + .wrapContentWidth() + .constrainAs(downloadStatus) { + top.linkTo(parent.top) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + } + ) { + Text( + modifier = Modifier.padding(end = dimensions().spacing4x), + text = getDownloadStatusText(assetTransferStatus), + color = MaterialTheme.wireColorScheme.run { + if (assetTransferStatus.isFailed()) error else secondaryText + }, + style = MaterialTheme.wireTypography.subline01 + ) + DownloadStatusIcon(assetTransferStatus) } } } @@ -327,11 +317,5 @@ private fun isNotClickable(assetTransferStatus: AssetTransferStatus) = @Suppress("MagicNumber") @Stable private fun provideAssetDescription(assetExtension: String, assetSizeInBytes: Long): String { - val oneKB = 1024L - val oneMB = oneKB * oneKB - return when { - assetSizeInBytes < oneKB -> "${assetExtension.uppercase()} ($assetSizeInBytes B)" - assetSizeInBytes in oneKB..oneMB -> "${assetExtension.uppercase()} (${assetSizeInBytes / oneKB} KB)" - else -> "${assetExtension.uppercase()} (${((assetSizeInBytes / oneMB) * 100.0).roundToInt() / 100.0} MB)" // 2 decimals round off - } + return "${assetExtension.uppercase()} (${DeviceUtil.formatSize(assetSizeInBytes)})" } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageState.kt index e2a8262caa6..6d13cd703a4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageState.kt @@ -17,4 +17,16 @@ */ package com.wire.android.ui.home.conversations.sendmessage -data class SendMessageState(val messageSent: Boolean, val inProgress: Boolean) +import com.wire.kalium.logic.data.id.ConversationId + +data class SendMessageState( + val inProgress: Boolean = false, + val afterMessageSendAction: SendMessageAction = SendMessageAction.None +) + +sealed class SendMessageAction { + data object None : SendMessageAction() + data object NavigateBack : SendMessageAction() + data class NavigateToConversation(val conversationId: ConversationId) : SendMessageAction() + data object NavigateToHome : SendMessageAction() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index f0c3f6d6c59..a3301d5e8e2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -21,13 +21,15 @@ package com.wire.android.ui.home.conversations.sendmessage import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.appLogger import com.wire.android.media.PingRinger import com.wire.android.model.SnackBarMessage +import com.wire.android.navigation.SavedStateViewModel import com.wire.android.ui.home.conversations.AssetTooLargeDialogState +import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.SureAboutMessagingDialogState import com.wire.android.ui.home.conversations.model.AssetBundle @@ -36,6 +38,8 @@ import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase import com.wire.android.ui.home.messagecomposer.model.ComposableMessageBundle import com.wire.android.ui.home.messagecomposer.model.MessageBundle import com.wire.android.ui.home.messagecomposer.model.Ping +import com.wire.android.ui.navArgs +import com.wire.android.ui.sharing.SendMessagesSnackbarMessages import com.wire.android.util.AUDIO_MIME_TYPE import com.wire.android.util.ImageUtil import com.wire.android.util.dispatchers.DispatcherProvider @@ -45,6 +49,7 @@ import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageResult import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase @@ -60,12 +65,13 @@ import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase import com.wire.kalium.logic.functional.Either -import com.wire.kalium.logic.functional.isRight import com.wire.kalium.logic.functional.onFailure import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -73,6 +79,7 @@ import javax.inject.Inject @Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class SendMessageViewModel @Inject constructor( + override val savedStateHandle: SavedStateHandle, private val sendAssetMessage: ScheduleNewAssetMessageUseCase, private val sendTextMessage: SendTextMessageUseCase, private val sendEditTextMessage: SendEditTextMessageUseCase, @@ -90,7 +97,10 @@ class SendMessageViewModel @Inject constructor( private val observeConversationUnderLegalHoldNotified: ObserveConversationUnderLegalHoldNotifiedUseCase, private val sendLocation: SendLocationUseCase, private val removeMessageDraft: RemoveMessageDraftUseCase, -) : ViewModel() { +) : SavedStateViewModel(savedStateHandle) { + + private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + val conversationId: QualifiedID = conversationNavArgs.conversationId private val _infoMessage = MutableSharedFlow() val infoMessage = _infoMessage.asSharedFlow() @@ -103,12 +113,18 @@ class SendMessageViewModel @Inject constructor( SureAboutMessagingDialogState.Hidden ) - var viewState: SendMessageState by mutableStateOf( - SendMessageState( - messageSent = false, - inProgress = false - ) - ) + init { + conversationNavArgs.pendingBundles?.let { assetBundles -> + trySendMessages( + assetBundles.map { assetBundle -> + ComposableMessageBundle.AttachmentPickedBundle( + conversationId, + assetBundle + ) + } + ) + } + } private fun onSnackbarMessage(type: SnackBarMessage) = viewModelScope.launch { _infoMessage.emit(type) @@ -121,25 +137,50 @@ class SendMessageViewModel @Inject constructor( observeConversationUnderLegalHoldNotified(conversationId).first().let { !it } fun trySendMessage(messageBundle: MessageBundle) { - viewModelScope.launch { - when { - shouldInformAboutDegradedBeforeSendingMessage(messageBundle.conversationId) -> - sureAboutMessagingDialogState = SureAboutMessagingDialogState.Visible.ConversationVerificationDegraded(messageBundle) + trySendMessages(listOf(messageBundle)) + } - shouldInformAboutUnderLegalHoldBeforeSendingMessage(messageBundle.conversationId) -> - sureAboutMessagingDialogState = - SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.BeforeSending(messageBundle) + fun trySendMessages(messageBundleList: List) { + if (messageBundleList.size > MAX_LIMIT_MESSAGE_SEND) { + onSnackbarMessage(SendMessagesSnackbarMessages.MaxAmountOfAssetsReached) + } else { + val messageBundleMap = messageBundleList.groupBy { it.conversationId } + messageBundleMap.forEach { (conversationId, bundles) -> + viewModelScope.launch { + when { + shouldInformAboutDegradedBeforeSendingMessage(conversationId) -> + sureAboutMessagingDialogState = + SureAboutMessagingDialogState.Visible.ConversationVerificationDegraded(conversationId, bundles) + + shouldInformAboutUnderLegalHoldBeforeSendingMessage(conversationId) -> + sureAboutMessagingDialogState = + SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.BeforeSending( + conversationId, + messageBundleList + ) - else -> sendMessage(messageBundle) + else -> sendMessages(messageBundleList) + } + } } } } + private suspend fun sendMessages(messageBundleList: List) { + val jobs: MutableCollection = mutableListOf() + messageBundleList.forEach { + val job = viewModelScope.launch { + sendMessage(it) + } + jobs.add(job) + } + jobs.joinAll() + } + @Suppress("LongMethod") private suspend fun sendMessage(messageBundle: MessageBundle) { when (messageBundle) { is ComposableMessageBundle.EditMessageBundle -> { - beforeSendingMessage() removeMessageDraft(messageBundle.conversationId) sendTypingEvent(messageBundle.conversationId, TypingIndicatorMode.STOPPED) with(messageBundle) { @@ -150,11 +191,14 @@ class SendMessageViewModel @Inject constructor( mentions = newMentions.map { it.intoMessageMention() }, ) .handleLegalHoldFailureAfterSendingMessage(conversationId) - .handleAfterMessageResult() } } is ComposableMessageBundle.AttachmentPickedBundle -> { + sendAttachment(messageBundle.assetBundle, messageBundle.conversationId) + } + + is ComposableMessageBundle.UriPickedBundle -> { handleAssetMessageBundle( attachmentUri = messageBundle.attachmentUri, conversationId = messageBundle.conversationId @@ -170,7 +214,6 @@ class SendMessageViewModel @Inject constructor( } is ComposableMessageBundle.SendTextMessageBundle -> { - beforeSendingMessage() removeMessageDraft(messageBundle.conversationId) sendTypingEvent(messageBundle.conversationId, TypingIndicatorMode.STOPPED) with(messageBundle) { @@ -181,25 +224,20 @@ class SendMessageViewModel @Inject constructor( quotedMessageId = quotedMessageId ) .handleLegalHoldFailureAfterSendingMessage(conversationId) - .handleAfterMessageResult() } } is ComposableMessageBundle.LocationBundle -> { - beforeSendingMessage() with(messageBundle) { sendLocation(conversationId, location.latitude.toFloat(), location.longitude.toFloat(), locationName, zoom) .handleLegalHoldFailureAfterSendingMessage(conversationId) - .handleAfterMessageResult() } } is Ping -> { - beforeSendingMessage() pingRinger.ping(R.raw.ping_from_me, isReceivingPing = false) sendKnockUseCase(conversationId = messageBundle.conversationId, hotKnock = false) .handleLegalHoldFailureAfterSendingMessage(messageBundle.conversationId) - .handleAfterMessageResult() } } } @@ -233,7 +271,6 @@ class SendMessageViewModel @Inject constructor( } internal fun sendAttachment(attachmentBundle: AssetBundle?, conversationId: ConversationId) { - beforeSendingMessage() viewModelScope.launch { withContext(dispatchers.io()) { attachmentBundle?.run { @@ -254,7 +291,6 @@ class SendMessageViewModel @Inject constructor( audioLengthInMs = 0L ) .handleLegalHoldFailureAfterSendingMessage(conversationId) - .handleAfterMessageResult() } AttachmentType.VIDEO, @@ -275,7 +311,6 @@ class SendMessageViewModel @Inject constructor( ) ) .handleLegalHoldFailureAfterSendingMessage(conversationId) - .handleAfterMessageResult() } catch (e: OutOfMemoryError) { appLogger.e("There was an OutOfMemory error while uploading the asset") onSnackbarMessage(ConversationSnackbarMessages.ErrorSendingAsset) @@ -289,8 +324,17 @@ class SendMessageViewModel @Inject constructor( private fun CoreFailure.handleLegalHoldFailureAfterSendingMessage(conversationId: ConversationId) = also { if (this is LegalHoldEnabledForConversationFailure) { - sureAboutMessagingDialogState = - SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.AfterSending(this.messageId, conversationId) + sureAboutMessagingDialogState = when (val currentState = sureAboutMessagingDialogState) { + // if multiple messages will fail, update messageIdList to retry sending all of them + is SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.AfterSending -> currentState.copy( + messageIdList = currentState.messageIdList.plus(messageId) + ) + + SureAboutMessagingDialogState.Hidden, + is SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.BeforeSending, + is SureAboutMessagingDialogState.Visible.ConversationVerificationDegraded -> + SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.AfterSending(conversationId, listOf(messageId)) + } } } @@ -307,6 +351,12 @@ class SendMessageViewModel @Inject constructor( } } + fun retrySendingMessages(messageIdList: List, conversationId: ConversationId) { + messageIdList.forEach { + retrySendingMessage(it, conversationId) + } + } + fun retrySendingMessage(messageId: String, conversationId: ConversationId) { viewModelScope.launch { retryFailedMessage(messageId = messageId, conversationId = conversationId) @@ -323,13 +373,13 @@ class SendMessageViewModel @Inject constructor( it.markAsNotified(it.conversationId) when (it) { is SureAboutMessagingDialogState.Visible.ConversationVerificationDegraded -> - trySendMessage(it.messageBundleToSend) + trySendMessages(it.messageBundleListToSend) is SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.BeforeSending -> - trySendMessage(it.messageBundleToSend) + trySendMessages(it.messageBundleListToSend) is SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.AfterSending -> - retrySendingMessage(it.messageId, it.conversationId) + retrySendingMessages(it.messageIdList, it.conversationId) } } } @@ -353,11 +403,7 @@ class SendMessageViewModel @Inject constructor( sureAboutMessagingDialogState = SureAboutMessagingDialogState.Hidden } - private fun beforeSendingMessage() { - viewState = viewState.copy(messageSent = false, inProgress = true) - } - - private fun Either.handleAfterMessageResult() { - viewState = viewState.copy(messageSent = this.isRight(), inProgress = false) + private companion object { + const val MAX_LIMIT_MESSAGE_SEND = 20 } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt index 1f315a66f14..1b134fd95a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt @@ -99,7 +99,7 @@ fun AdditionalOptionSubMenu( onCloseAdditionalAttachment: () -> Unit, onRecordAudioMessageClicked: () -> Unit, additionalOptionsState: AdditionalOptionSubMenuState, - onImagePicked: (Uri) -> Unit, + onImagesPicked: (List) -> Unit, onAttachmentPicked: (UriAsset) -> Unit, onAudioRecorded: (UriAsset) -> Unit, onLocationPicked: (GeoLocatedAddress) -> Unit, @@ -109,7 +109,7 @@ fun AdditionalOptionSubMenu( ) { Box(modifier = modifier) { AttachmentOptionsComponent( - onImagePicked = onImagePicked, + onImagesPicked = onImagesPicked, onAttachmentPicked = onAttachmentPicked, tempWritableImageUri = tempWritableImageUri, tempWritableVideoUri = tempWritableVideoUri, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt index 9069d2468f5..b0105a3879c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.messagecomposer import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement @@ -60,7 +61,7 @@ import com.wire.android.util.ui.KeyboardHeight @Composable fun AttachmentOptionsComponent( - onImagePicked: (Uri) -> Unit, + onImagesPicked: (List) -> Unit, onAttachmentPicked: (UriAsset) -> Unit, onRecordAudioMessageClicked: () -> Unit, tempWritableImageUri: Uri?, @@ -73,14 +74,14 @@ fun AttachmentOptionsComponent( val textMeasurer = rememberTextMeasurer() val attachmentOptions = buildAttachmentOptionItems( - isFileSharingEnabled, - tempWritableImageUri, - tempWritableVideoUri, - onImagePicked, - onAttachmentPicked, - onRecordAudioMessageClicked, - onLocationPickerClicked, - onCaptureVideoPermissionPermanentlyDenied + isFileSharingEnabled = isFileSharingEnabled, + tempWritableImageUri = tempWritableImageUri, + tempWritableVideoUri = tempWritableVideoUri, + onImagesPicked = onImagesPicked, + onFilePicked = onAttachmentPicked, + onRecordAudioMessageClicked = onRecordAudioMessageClicked, + onLocationPickerClicked = onLocationPickerClicked, + onPermissionPermanentlyDenied = onCaptureVideoPermissionPermanentlyDenied ) val labelStyle = MaterialTheme.wireTypography.button03 @@ -158,21 +159,46 @@ private fun calculateGridParams( fun FileBrowserFlow( onFilePicked: (Uri) -> Unit, onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit -): UseStorageRequestFlow { +): UseStorageRequestFlow { return rememberOpenFileBrowserFlow( - onFileBrowserItemPicked = onFilePicked, + contract = ActivityResultContracts.GetContent(), + onFileBrowserItemPicked = { uri -> + uri?.let(onFilePicked) + }, onPermissionDenied = { /* Nothing to do */ }, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied ) } @Composable -private fun GalleryFlow( - onFilePicked: (Uri) -> Unit, +fun MultipleFileBrowserFlow( + onFilesPicked: (List) -> Unit, + onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit +): UseStorageRequestFlow> { + return rememberOpenFileBrowserFlow( + contract = ActivityResultContracts.GetMultipleContents(), + onFileBrowserItemPicked = { uris -> + if (uris.isNotEmpty()) { + onFilesPicked(uris) + } + }, + onPermissionDenied = { /* Nothing to do */ }, + onPermissionPermanentlyDenied = onPermissionPermanentlyDenied + ) +} + +@Composable +private fun MultipleGalleryFlow( + onImagesPicked: (List) -> Unit, onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit -): UseStorageRequestFlow { +): UseStorageRequestFlow> { return rememberOpenGalleryFlow( - onGalleryItemPicked = onFilePicked, + contract = ActivityResultContracts.GetMultipleContents(), + onGalleryItemPicked = { uris -> + if (uris.isNotEmpty()) { + onImagesPicked(uris) + } + }, onPermissionDenied = { /* Nothing to do */ }, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied ) @@ -200,7 +226,7 @@ private fun TakePictureFlow( } @Composable -private fun CaptureVideoFlow( +private fun captureVideoFlow( tempWritableVideoUri: Uri?, onVideoCaptured: (Uri) -> Unit, onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit, @@ -221,18 +247,18 @@ private fun buildAttachmentOptionItems( isFileSharingEnabled: Boolean, tempWritableImageUri: Uri?, tempWritableVideoUri: Uri?, - onImagePicked: (Uri) -> Unit, + onImagesPicked: (List) -> Unit, onFilePicked: (UriAsset) -> Unit, onRecordAudioMessageClicked: () -> Unit, onLocationPickerClicked: () -> Unit, onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit ): List { - val fileFlow = FileBrowserFlow( - remember { { onFilePicked(UriAsset(it, false)) } }, + val fileFlow = MultipleFileBrowserFlow( + remember { { onImagesPicked(it) } }, onPermissionPermanentlyDenied ) - val galleryFlow = GalleryFlow( - remember { { onImagePicked(it) } }, + val galleryFlow = MultipleGalleryFlow( + remember { { onImagesPicked(it) } }, onPermissionPermanentlyDenied ) val cameraFlow = TakePictureFlow( @@ -240,7 +266,7 @@ private fun buildAttachmentOptionItems( remember { { onFilePicked(UriAsset(it, false)) } }, onPermissionPermanentlyDenied ) - val captureVideoFlow = CaptureVideoFlow( + val captureVideoFlow = captureVideoFlow( tempWritableVideoUri, remember { { onFilePicked(UriAsset(it, true)) } }, onPermissionPermanentlyDenied @@ -313,7 +339,7 @@ private data class AttachmentOptionItem( @Composable fun PreviewAttachmentComponents() { AttachmentOptionsComponent( - onImagePicked = {}, + onImagesPicked = {}, onAttachmentPicked = {}, isFileSharingEnabled = true, tempWritableImageUri = null, @@ -334,7 +360,7 @@ fun PreviewAttachmentOptionsComponentSmallScreen() { ) { AttachmentOptionsComponent( onAttachmentPicked = {}, - onImagePicked = {}, + onImagesPicked = {}, isFileSharingEnabled = true, tempWritableImageUri = null, tempWritableVideoUri = null, @@ -356,7 +382,7 @@ fun PreviewAttachmentOptionsComponentNormalScreen() { ) { AttachmentOptionsComponent( onAttachmentPicked = {}, - onImagePicked = {}, + onImagesPicked = {}, isFileSharingEnabled = true, tempWritableImageUri = null, tempWritableVideoUri = null, @@ -378,7 +404,7 @@ fun PreviewAttachmentOptionsComponentTabledScreen() { ) { AttachmentOptionsComponent( onAttachmentPicked = {}, - onImagePicked = {}, + onImagesPicked = {}, isFileSharingEnabled = true, tempWritableImageUri = null, tempWritableVideoUri = null, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index 1b18169353e..ee508464143 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -75,7 +75,7 @@ fun EnabledMessageComposer( onSearchMentionQueryChanged: (String) -> Unit, onTypingEvent: (Conversation.TypingIndicatorMode) -> Unit, onSendButtonClicked: () -> Unit, - onImagePicked: (Uri) -> Unit, + onImagesPicked: (List) -> Unit, onAttachmentPicked: (UriAsset) -> Unit, onAudioRecorded: (UriAsset) -> Unit, onLocationPicked: (GeoLocatedAddress) -> Unit, @@ -286,7 +286,7 @@ fun EnabledMessageComposer( onRecordAudioMessageClicked = ::toAudioRecording, onCloseAdditionalAttachment = ::toInitialAttachmentOptions, onLocationPickerClicked = ::toLocationPicker, - onImagePicked = onImagePicked, + onImagesPicked = onImagesPicked, onAttachmentPicked = onAttachmentPicked, onAudioRecorded = onAudioRecorded, onLocationPicked = onLocationPicked, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index fa3695895d0..34f5c226850 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -86,7 +86,7 @@ fun MessageComposer( tempWritableVideoUri: Uri?, tempWritableImageUri: Uri?, onTypingEvent: (TypingIndicatorMode) -> Unit, - onImagePicked: (Uri) -> Unit + onImagesPicked: (List) -> Unit ) { with(messageComposerStateHolder) { when (messageComposerViewState.value.interactionAvailability) { @@ -137,8 +137,8 @@ fun MessageComposer( clearMessage() }, onPingOptionClicked = { onSendMessageBundle(Ping(conversationId)) }, - onImagePicked = onImagePicked, - onAttachmentPicked = { onSendMessageBundle(ComposableMessageBundle.AttachmentPickedBundle(conversationId, it)) }, + onImagesPicked = onImagesPicked, + onAttachmentPicked = { onSendMessageBundle(ComposableMessageBundle.UriPickedBundle(conversationId, it)) }, onAudioRecorded = { onSendMessageBundle(ComposableMessageBundle.AudioMessageBundle(conversationId, it)) }, onLocationPicked = { onSendMessageBundle( @@ -282,7 +282,7 @@ private fun BaseComposerPreview( tempWritableVideoUri = null, tempWritableImageUri = null, onTypingEvent = { }, - onImagePicked = {} + onImagesPicked = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt index dbaecd99f2c..c9cf99702ab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageBundle.kt @@ -18,6 +18,7 @@ package com.wire.android.ui.home.messagecomposer.model import android.location.Location +import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.kalium.logic.data.id.ConversationId @@ -40,6 +41,11 @@ sealed class ComposableMessageBundle(override val conversationId: ConversationId ) : ComposableMessageBundle(conversationId) data class AttachmentPickedBundle( + override val conversationId: ConversationId, + val assetBundle: AssetBundle + ) : ComposableMessageBundle(conversationId) + + data class UriPickedBundle( override val conversationId: ConversationId, val attachmentUri: UriAsset ) : ComposableMessageBundle(conversationId) diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index f83e4af30da..1270fde5122 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt @@ -36,7 +36,6 @@ import com.wire.android.mapper.toUIPreview import com.wire.android.model.ImageAsset import com.wire.android.model.SnackBarMessage import com.wire.android.model.UserAvatarData -import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.search.DEFAULT_SEARCH_QUERY_DEBOUNCE import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase import com.wire.android.ui.home.conversationslist.model.BlockState @@ -46,21 +45,14 @@ import com.wire.android.ui.home.conversationslist.parseConversationEventType import com.wire.android.ui.home.conversationslist.parsePrivateConversationEventType import com.wire.android.ui.home.conversationslist.showLegalHoldIndicator import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration -import com.wire.android.util.ImageUtil import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.android.util.getAudioLengthInMs import com.wire.android.util.parcelableArrayList import com.wire.android.util.ui.WireSessionImageLoader -import com.wire.kalium.logic.data.asset.AttachmentType -import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.message.SelfDeletionTimer.Companion.SELF_DELETION_LOG_TAG -import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageResult -import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetailsUseCase -import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase @@ -70,7 +62,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -79,7 +70,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -91,9 +81,6 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( private val getSelf: GetSelfUserUseCase, private val userTypeMapper: UserTypeMapper, private val observeConversationListDetails: ObserveConversationListDetailsUseCase, - private val sendAssetMessage: ScheduleNewAssetMessageUseCase, - private val sendTextMessage: SendTextMessageUseCase, - private val kaliumFileSystem: KaliumFileSystem, private val handleUriAsset: HandleUriAssetUseCase, private val persistNewSelfDeletionTimerUseCase: PersistNewSelfDeletionTimerUseCase, private val observeSelfDeletionSettingsForConversation: ObserveSelfDeletionTimerSettingsForConversationUseCase, @@ -112,13 +99,17 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( private val _infoMessage = MutableSharedFlow() val infoMessage = _infoMessage.asSharedFlow() - init { + fun init() { viewModelScope.launch { loadUserAvatar() observeConversationWithSearch() } } + fun onRemove(index: Int) { + importMediaState = importMediaState.copy(importedAssets = importMediaState.importedAssets.removeAt(index)) + } + private fun loadUserAvatar() = viewModelScope.launch(dispatchers.io()) { getSelf().collect { selfUser -> withContext(dispatchers.main()) { @@ -303,7 +294,7 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( handleImportedAsset(uri)?.let { importedAsset -> if (importedAsset.assetSizeExceeded != null) { onSnackbarMessage( - ImportMediaSnackbarMessages.MaxAssetSizeExceeded(importedAsset.assetSizeExceeded!!) + SendMessagesSnackbarMessages.MaxAssetSizeExceeded(importedAsset.assetSizeExceeded) ) } importMediaState = importMediaState.copy(importedAssets = persistentListOf(importedAsset)) @@ -320,67 +311,10 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( importMediaState = importMediaState.copy(importedAssets = importedMediaAssets.toPersistentList()) importedMediaAssets.firstOrNull { it.assetSizeExceeded != null }?.let { - onSnackbarMessage(ImportMediaSnackbarMessages.MaxAssetSizeExceeded(it.assetSizeExceeded!!)) + onSnackbarMessage(SendMessagesSnackbarMessages.MaxAssetSizeExceeded(it.assetSizeExceeded!!)) } } - fun checkRestrictionsAndSendImportedMedia(onSent: (ConversationId) -> Unit) = - viewModelScope.launch(dispatchers.default()) { - val conversation = - importMediaState.selectedConversationItem.firstOrNull() ?: return@launch - val assetsToSend = importMediaState.importedAssets - val textToSend = importMediaState.importedText - - if (assetsToSend.size > MAX_LIMIT_MEDIA_IMPORT) { - onSnackbarMessage(ImportMediaSnackbarMessages.MaxAmountOfAssetsReached) - } else { - val jobs: MutableCollection = mutableListOf() - - textToSend?.let { - sendTextMessage( - conversationId = conversation.conversationId, - text = it - ) - } ?: assetsToSend.forEach { importedAsset -> - val isImage = importedAsset is ImportedMediaAsset.Image - val job = viewModelScope.launch { - sendAssetMessage( - conversationId = conversation.conversationId, - assetDataPath = importedAsset.assetBundle.dataPath, - assetName = importedAsset.assetBundle.fileName, - assetDataSize = importedAsset.assetBundle.dataSize, - assetMimeType = importedAsset.assetBundle.mimeType, - assetWidth = if (isImage) (importedAsset as ImportedMediaAsset.Image).width else 0, - assetHeight = if (isImage) (importedAsset as ImportedMediaAsset.Image).height else 0, - audioLengthInMs = getAudioLengthInMs( - dataPath = importedAsset.assetBundle.dataPath, - mimeType = importedAsset.assetBundle.mimeType, - ) - ).also { - val logConversationId = conversation.conversationId.toLogString() - if (it is ScheduleNewAssetMessageResult.Failure) { - appLogger.e( - "Failed to import asset message to " + - "conversationId=$logConversationId" - ) - } else { - appLogger.d( - "Success importing asset message to " + - "conversationId=$logConversationId" - ) - } - } - } - jobs.add(job) - } - - jobs.joinAll() - withContext(dispatchers.main()) { - onSent(conversation.conversationId) - } - } - } - fun onNewConversationPicked(conversationId: ConversationId) = viewModelScope.launch { importMediaState = importMediaState.copy( selfDeletingTimer = observeSelfDeletionSettingsForConversation( @@ -415,46 +349,16 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( private suspend fun handleImportedAsset(uri: Uri): ImportedMediaAsset? = withContext(dispatchers.io()) { when (val result = handleUriAsset.invoke(uri, saveToDeviceIfInvalid = false)) { - is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> mapToImportedAsset(result.assetBundle, result.maxLimitInMB) + is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> ImportedMediaAsset(result.assetBundle, result.maxLimitInMB) HandleUriAssetUseCase.Result.Failure.Unknown -> null - is HandleUriAssetUseCase.Result.Success -> mapToImportedAsset(result.assetBundle, null) - } - } - - private fun mapToImportedAsset(assetBundle: AssetBundle, assetSizeExceeded: Int?): ImportedMediaAsset { - return when (assetBundle.assetType) { - AttachmentType.IMAGE -> { - val (imgWidth, imgHeight) = ImageUtil.extractImageWidthAndHeight( - kaliumFileSystem, - assetBundle.dataPath - ) - ImportedMediaAsset.Image( - assetBundle = assetBundle, - width = imgWidth, - height = imgHeight, - assetSizeExceeded = assetSizeExceeded - ) - } - - AttachmentType.GENERIC_FILE, - AttachmentType.AUDIO, - AttachmentType.VIDEO -> { - ImportedMediaAsset.GenericAsset( - assetBundle = assetBundle, - assetSizeExceeded = assetSizeExceeded - ) - } + is HandleUriAssetUseCase.Result.Success -> ImportedMediaAsset(result.assetBundle, null) } } fun onSnackbarMessage(type: SnackBarMessage) = viewModelScope.launch { _infoMessage.emit(type) } - - private companion object { - const val MAX_LIMIT_MEDIA_IMPORT = 20 - } } @Stable diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index 2aabe3bb63a..2ad28bb7f49 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -18,17 +18,18 @@ package com.wire.android.ui.sharing import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider @@ -62,7 +63,9 @@ import com.wire.android.ui.common.bottomsheet.MenuModalSheetLayout import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.error.ErrorIcon import com.wire.android.ui.common.progress.WireCircularProgressIndicator +import com.wire.android.ui.common.remove.RemoveIcon import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar @@ -70,6 +73,11 @@ import com.wire.android.ui.common.topappbar.search.SearchBarState import com.wire.android.ui.common.topappbar.search.SearchTopBar import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.home.FeatureFlagState +import com.wire.android.ui.home.conversations.AssetTooLargeDialog +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel +import com.wire.android.ui.home.conversations.media.preview.AssetTilePreview +import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMenuItems import com.wire.android.ui.home.conversationslist.common.ConversationList @@ -77,24 +85,30 @@ import com.wire.android.ui.home.conversationslist.model.ConversationFolder import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration import com.wire.android.ui.home.newconversation.common.SendContentButton import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper import com.wire.android.util.extension.getActivity import com.wire.android.util.ui.LinkText import com.wire.android.util.ui.LinkTextData +import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.util.isPositiveNotNull import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import okio.Path.Companion.toPath @RootNavGraph @Destination @Composable fun ImportMediaScreen( navigator: Navigator, - featureFlagNotificationViewModel: FeatureFlagNotificationViewModel = hiltViewModel() + featureFlagNotificationViewModel: FeatureFlagNotificationViewModel = hiltViewModel(), + checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = hiltViewModel(), + importMediaViewModel: ImportMediaAuthenticatedViewModel = hiltViewModel(), ) { when (val fileSharingRestrictedState = featureFlagNotificationViewModel.featureFlagState.fileSharingRestrictedState) { @@ -106,7 +120,7 @@ fun ImportMediaScreen( } FeatureFlagState.SharingRestrictedState.RESTRICTED_IN_TEAM -> { - val importMediaViewModel: ImportMediaAuthenticatedViewModel = hiltViewModel() + ImportMediaRestrictedContent( fileSharingRestrictedState = fileSharingRestrictedState, importMediaAuthenticatedState = importMediaViewModel.importMediaState, @@ -115,25 +129,40 @@ fun ImportMediaScreen( } FeatureFlagState.SharingRestrictedState.NONE -> { - val importMediaViewModel: ImportMediaAuthenticatedViewModel = hiltViewModel() ImportMediaRegularContent( importMediaAuthenticatedState = importMediaViewModel.importMediaState, onSearchQueryChanged = importMediaViewModel::onSearchQueryChanged, onConversationClicked = importMediaViewModel::onConversationClicked, checkRestrictionsAndSendImportedMedia = { - importMediaViewModel.checkRestrictionsAndSendImportedMedia { - navigator.navigate( - NavigationCommand( - ConversationScreenDestination(it), - BackStackMode.REMOVE_CURRENT - ) + importMediaViewModel.importMediaState.selectedConversationItem.firstOrNull()?.let { conversationItem -> + checkAssetRestrictionsViewModel.checkRestrictions( + importedMediaList = importMediaViewModel.importMediaState.importedAssets, + onSuccess = { + navigator.navigate( + NavigationCommand( + ConversationScreenDestination( + ConversationNavArgs( + conversationId = conversationItem.conversationId, + pendingBundles = ArrayList(it) + ) + ), + BackStackMode.UPDATE_EXISTED + ), + ) + } ) } }, onNewSelfDeletionTimerPicked = importMediaViewModel::onNewSelfDeletionTimerPicked, infoMessage = importMediaViewModel.infoMessage, navigateBack = navigator.finish, + onRemoveAsset = importMediaViewModel::onRemove + ) + AssetTooLargeDialog( + dialogState = checkAssetRestrictionsViewModel.assetTooLargeDialogState, + hideDialog = checkAssetRestrictionsViewModel::hideDialog ) + val context = LocalContext.current LaunchedEffect(importMediaViewModel.importMediaState.importedAssets) { if (importMediaViewModel.importMediaState.importedAssets.isEmpty()) { @@ -156,6 +185,7 @@ fun ImportMediaRestrictedContent( fileSharingRestrictedState: FeatureFlagState.SharingRestrictedState, importMediaAuthenticatedState: ImportMediaAuthenticatedState, navigateBack: () -> Unit, + modifier: Modifier = Modifier, ) { with(importMediaAuthenticatedState) { WireScaffold( @@ -173,7 +203,7 @@ fun ImportMediaRestrictedContent( } ) }, - modifier = Modifier.background(colorsScheme().background), + modifier = modifier.background(colorsScheme().background), content = { internalPadding -> FileSharingRestrictedContent( internalPadding, @@ -194,6 +224,8 @@ fun ImportMediaRegularContent( onNewSelfDeletionTimerPicked: (selfDeletionDuration: SelfDeletionDuration) -> Unit, infoMessage: SharedFlow, navigateBack: () -> Unit, + modifier: Modifier = Modifier, + onRemoveAsset: (index: Int) -> Unit ) { val importMediaScreenState = rememberImportMediaScreenState() @@ -214,14 +246,15 @@ fun ImportMediaRegularContent( } ) }, - modifier = Modifier.background(colorsScheme().background), + modifier = modifier.background(colorsScheme().background), content = { internalPadding -> ImportMediaContent( state = this, internalPadding = internalPadding, onSearchQueryChanged = onSearchQueryChanged, onConversationClicked = onConversationClicked, - searchBarState = importMediaScreenState.searchBarState + searchBarState = importMediaScreenState.searchBarState, + onRemoveAsset = onRemoveAsset ) }, bottomBar = { @@ -249,6 +282,7 @@ fun ImportMediaRegularContent( fun ImportMediaLoggedOutContent( fileSharingRestrictedState: FeatureFlagState.SharingRestrictedState, navigateBack: () -> Unit, + modifier: Modifier = Modifier ) { WireScaffold( topBar = { @@ -259,7 +293,7 @@ fun ImportMediaLoggedOutContent( title = stringResource(id = R.string.import_media_content_title), ) }, - modifier = Modifier.background(colorsScheme().background), + modifier = modifier.background(colorsScheme().background), content = { internalPadding -> FileSharingRestrictedContent( internalPadding, @@ -274,7 +308,8 @@ fun ImportMediaLoggedOutContent( fun FileSharingRestrictedContent( internalPadding: PaddingValues, sharingRestrictedState: FeatureFlagState.SharingRestrictedState, - openWireAction: () -> Unit + openWireAction: () -> Unit, + modifier: Modifier = Modifier ) { val context = LocalContext.current val learnMoreUrl = stringResource(R.string.file_sharing_restricted_learn_more_link) @@ -282,7 +317,7 @@ fun FileSharingRestrictedContent( Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = Modifier + modifier = modifier .fillMaxSize() .padding(internalPadding) .padding(horizontal = dimensions().spacing48x) @@ -347,12 +382,12 @@ private fun ImportMediaBottomBar( } @Composable -@OptIn(ExperimentalFoundationApi::class) private fun ImportMediaContent( state: ImportMediaAuthenticatedState, internalPadding: PaddingValues, onSearchQueryChanged: (searchQuery: TextFieldValue) -> Unit, onConversationClicked: (conversationId: ConversationId) -> Unit, + onRemoveAsset: (index: Int) -> Unit, searchBarState: SearchBarState ) { val importedItemsList: PersistentList = state.importedAssets @@ -369,7 +404,7 @@ private fun ImportMediaContent( if (state.isImporting) { Box( Modifier - .height(dimensions().spacing100x) + .height(dimensions().spacing120x) .fillMaxWidth() .align(Alignment.CenterHorizontally) ) { @@ -380,25 +415,58 @@ private fun ImportMediaContent( ) } } else if (!isMultipleImport) { - Box(modifier = Modifier.padding(horizontal = dimensions().spacing16x)) { - ImportedMediaItemView( - item = importedItemsList.first(), - isMultipleImport = false + Box( + modifier = Modifier + .padding(horizontal = dimensions().spacing16x) + .height(dimensions().spacing120x) + ) { + AssetTilePreview( + modifier = Modifier.fillMaxHeight(), + assetBundle = importedItemsList.first().assetBundle, + showOnlyExtension = false, + onClick = {} ) } } else { LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x), - contentPadding = PaddingValues(start = dimensions().spacing16x, end = dimensions().spacing16x) + modifier = Modifier + .fillMaxWidth() + .height(dimensions().spacing120x), + contentPadding = PaddingValues(start = dimensions().spacing8x, end = dimensions().spacing8x) ) { items( count = importedItemsList.size, ) { index -> - ImportedMediaItemView( - item = importedItemsList[index], - isMultipleImport = true - ) + Box( + modifier = Modifier + .width(dimensions().spacing120x) + .fillMaxHeight() + ) { + val assetSize = dimensions().spacing120x - dimensions().spacing16x + AssetTilePreview( + modifier = Modifier + .width(assetSize) + .height(assetSize) + .align(Alignment.Center), + assetBundle = importedItemsList[index].assetBundle, + showOnlyExtension = false, + onClick = {} + ) + + if (importedItemsList.size > 1) { + RemoveIcon( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { onRemoveAsset(index) }, + contentDescription = stringResource(id = R.string.remove_asset_description) + ) + } + if (importedItemsList[index].assetSizeExceeded != null) { + ErrorIcon( + stringResource(id = R.string.asset_attention_description), + modifier = Modifier.align(Alignment.Center) + ) + } + } } } } @@ -460,33 +528,91 @@ private fun SnackBarMessage( @Preview(showBackground = true) @Composable fun PreviewImportMediaScreenLoggedOut() { - ImportMediaLoggedOutContent(FeatureFlagState.SharingRestrictedState.NO_USER) {} + WireTheme { + ImportMediaLoggedOutContent(FeatureFlagState.SharingRestrictedState.NO_USER, {}) + } } @Preview(showBackground = true) @Composable fun PreviewImportMediaScreenRestricted() { - ImportMediaRestrictedContent( - FeatureFlagState.SharingRestrictedState.RESTRICTED_IN_TEAM, - ImportMediaAuthenticatedState() - ) {} + WireTheme { + ImportMediaRestrictedContent( + fileSharingRestrictedState = FeatureFlagState.SharingRestrictedState.RESTRICTED_IN_TEAM, + importMediaAuthenticatedState = ImportMediaAuthenticatedState(), + {} + ) + } } @Preview(showBackground = true) @Composable fun PreviewImportMediaScreenRegular() { - ImportMediaRegularContent( - ImportMediaAuthenticatedState(), - {}, - {}, - {}, - {}, - MutableSharedFlow() - ) {} + WireTheme { + ImportMediaRegularContent( + importMediaAuthenticatedState = ImportMediaAuthenticatedState( + importedAssets = persistentListOf( + ImportedMediaAsset( + AssetBundle( + "key", + "image/png", + "".toPath(), + 20, + "preview.png", + assetType = AttachmentType.IMAGE + ), + assetSizeExceeded = null + ), + ImportedMediaAsset( + AssetBundle( + "key1", + "video/mp4", + "".toPath(), + 20, + "preview.mp4", + assetType = AttachmentType.VIDEO + ), + assetSizeExceeded = null + ), + ImportedMediaAsset( + AssetBundle( + "key2", + "audio/mp3", + "".toPath(), + 24000000, + "preview.mp3", + assetType = AttachmentType.AUDIO + ), + assetSizeExceeded = 20 + ), + ImportedMediaAsset( + AssetBundle( + "key3", + "document/pdf", + "".toPath(), + 20, + "preview.pdf", + assetType = AttachmentType.GENERIC_FILE + ), + assetSizeExceeded = null + ) + ), + ), + onSearchQueryChanged = {}, + onConversationClicked = {}, + checkRestrictionsAndSendImportedMedia = {}, + onNewSelfDeletionTimerPicked = {}, + infoMessage = MutableSharedFlow(), + onRemoveAsset = { _ -> }, + navigateBack = {} + ) + } } @Preview(showBackground = true) @Composable fun PreviewImportMediaBottomBar() { - ImportMediaBottomBar(ImportMediaAuthenticatedState(), rememberImportMediaScreenState()) {} + WireTheme { + ImportMediaBottomBar(ImportMediaAuthenticatedState(), rememberImportMediaScreenState()) {} + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaAsset.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaAsset.kt index 1ea9271e704..97ffb1137f5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaAsset.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaAsset.kt @@ -17,24 +17,9 @@ */ package com.wire.android.ui.sharing -import com.wire.android.model.ImageAsset import com.wire.android.ui.home.conversations.model.AssetBundle -sealed class ImportedMediaAsset( - open val assetBundle: AssetBundle, - open val assetSizeExceeded: Int? -) { - class GenericAsset( - override val assetBundle: AssetBundle, - override val assetSizeExceeded: Int?, - ) : ImportedMediaAsset(assetBundle, assetSizeExceeded) - - class Image( - val width: Int, - val height: Int, - override val assetBundle: AssetBundle, - override val assetSizeExceeded: Int?, - ) : ImportedMediaAsset(assetBundle, assetSizeExceeded) { - val localImageAsset = ImageAsset.Local(assetBundle.dataPath, assetBundle.key) - } -} +data class ImportedMediaAsset( + val assetBundle: AssetBundle, + val assetSizeExceeded: Int? +) diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt deleted file mode 100644 index 424b8b62378..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.sharing - -import androidx.compose.runtime.Composable -import com.wire.android.model.Clickable -import com.wire.android.ui.home.conversations.model.MessageGenericAsset -import com.wire.android.ui.home.conversations.model.MessageImage -import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMessageParams -import com.wire.kalium.logic.data.asset.AssetTransferStatus -import com.wire.kalium.logic.util.fileExtension -import com.wire.kalium.logic.util.splitFileExtension - -@Composable -fun ImportedMediaItemView(item: ImportedMediaAsset, isMultipleImport: Boolean) { - when (item) { - is ImportedMediaAsset.GenericAsset -> ImportedGenericAssetView(item, isMultipleImport) - is ImportedMediaAsset.Image -> ImportedImageView(item, isMultipleImport) - } -} - -@Composable -fun ImportedImageView(item: ImportedMediaAsset.Image, isMultipleImport: Boolean) { - MessageImage( - asset = item.localImageAsset, - imgParams = ImageMessageParams(item.width, item.height), - transferStatus = AssetTransferStatus.NOT_DOWNLOADED, - onImageClick = Clickable(enabled = false), - shouldFillMaxWidth = !isMultipleImport, - ) -} - -@Composable -fun ImportedGenericAssetView(item: ImportedMediaAsset.GenericAsset, isMultipleImport: Boolean) { - MessageGenericAsset( - assetName = item.assetBundle.fileName.splitFileExtension().first, - assetExtension = item.assetBundle.fileName.fileExtension() ?: "", - assetSizeInBytes = item.assetBundle.dataSize, - onAssetClick = Clickable(enabled = false), - assetTransferStatus = AssetTransferStatus.NOT_DOWNLOADED, - shouldFillMaxWidth = !isMultipleImport, - isImportedMediaAsset = true - ) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaSnackbarMessages.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/SendMessagesSnackbarMessages.kt similarity index 64% rename from app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaSnackbarMessages.kt rename to app/src/main/kotlin/com/wire/android/ui/sharing/SendMessagesSnackbarMessages.kt index d1e5fcc34e0..108956bbc19 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaSnackbarMessages.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/SendMessagesSnackbarMessages.kt @@ -21,10 +21,9 @@ import com.wire.android.R import com.wire.android.model.SnackBarMessage import com.wire.android.util.ui.UIText -sealed class ImportMediaSnackbarMessages(override val uiText: UIText) : SnackBarMessage { - object MaxImageSize : ImportMediaSnackbarMessages(UIText.StringResource(R.string.error_conversation_max_image_size_limit)) - object MaxAmountOfAssetsReached : - ImportMediaSnackbarMessages(UIText.StringResource(R.string.error_limit_number_assets_imported_exceeded)) +sealed class SendMessagesSnackbarMessages(override val uiText: UIText) : SnackBarMessage { + data object MaxAmountOfAssetsReached : // TODO add max amount to string resource + SendMessagesSnackbarMessages(UIText.StringResource(R.string.error_limit_number_assets_imported_exceeded)) class MaxAssetSizeExceeded(assetSizeLimit: Int) : - ImportMediaSnackbarMessages(UIText.StringResource(R.string.error_conversation_max_asset_size_limit, assetSizeLimit)) + SendMessagesSnackbarMessages(UIText.StringResource(R.string.error_conversation_max_asset_size_limit, assetSizeLimit)) } diff --git a/app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt b/app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt index 74ad6cbbffb..ac8a2a99cfe 100644 --- a/app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/DeviceUtil.kt @@ -21,10 +21,9 @@ import android.os.Environment import android.os.StatFs object DeviceUtil { - private const val BYTES_IN_KILOBYTE = 1024 + private const val BYTES_IN_KILOBYTE = 1024L private const val BYTES_IN_MEGABYTE = BYTES_IN_KILOBYTE * 1024 private const val BYTES_IN_GIGABYTE = BYTES_IN_MEGABYTE * 1024 - private const val DIGITS_GROUP_SIZE = 3 // Number of digits between commas in formatted size. fun getAvailableInternalMemorySize(): String = try { val path = Environment.getDataDirectory() @@ -46,32 +45,12 @@ object DeviceUtil { "" } - private fun formatSize(sizeInBytes: Long): String { - var size = sizeInBytes - var suffix: String? = null - when { - size >= BYTES_IN_GIGABYTE -> { - suffix = "GB" - size /= BYTES_IN_GIGABYTE - } - - size >= BYTES_IN_MEGABYTE -> { - suffix = "MB" - size /= BYTES_IN_MEGABYTE - } - - size >= BYTES_IN_KILOBYTE -> { - suffix = "KB" - size /= BYTES_IN_KILOBYTE - } - } - val resultBuffer = StringBuilder(size.toString()) - var commaOffset = resultBuffer.length - DIGITS_GROUP_SIZE - while (commaOffset > 0) { - resultBuffer.insert(commaOffset, ',') - commaOffset -= DIGITS_GROUP_SIZE + fun formatSize(sizeInBytes: Long): String { + return when { + sizeInBytes < BYTES_IN_KILOBYTE -> "$sizeInBytes B" + sizeInBytes < BYTES_IN_MEGABYTE -> String.format("%.2f KB", sizeInBytes.toDouble() / BYTES_IN_KILOBYTE) + sizeInBytes < BYTES_IN_GIGABYTE -> String.format("%.2f MB", sizeInBytes.toDouble() / BYTES_IN_MEGABYTE) + else -> String.format("%.2f GB", sizeInBytes.toDouble() / BYTES_IN_GIGABYTE) } - suffix?.let { resultBuffer.append(it) } - return resultBuffer.toString() } } diff --git a/app/src/main/kotlin/com/wire/android/util/permission/OpenFileBrowserRequestFlow.kt b/app/src/main/kotlin/com/wire/android/util/permission/OpenFileBrowserRequestFlow.kt index fc43c0f132b..4af716f4c08 100644 --- a/app/src/main/kotlin/com/wire/android/util/permission/OpenFileBrowserRequestFlow.kt +++ b/app/src/main/kotlin/com/wire/android/util/permission/OpenFileBrowserRequestFlow.kt @@ -18,9 +18,9 @@ package com.wire.android.util.permission -import android.net.Uri import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -35,18 +35,19 @@ import com.wire.android.util.extension.getActivity * @param onPermissionDenied action to be executed when the permissions is denied */ @Composable -fun rememberOpenFileBrowserFlow( - onFileBrowserItemPicked: (Uri) -> Unit, +fun rememberOpenFileBrowserFlow( + contract: ActivityResultContract, + onFileBrowserItemPicked: (T) -> Unit, onPermissionDenied: () -> Unit, onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit -): UseStorageRequestFlow { +): UseStorageRequestFlow { val context = LocalContext.current - val openFileBrowserLauncher: ManagedActivityResultLauncher = + val openFileBrowserLauncher: ManagedActivityResultLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent() + contract ) { onChosenFileUri -> - onChosenFileUri?.let { onFileBrowserItemPicked(it) } + onFileBrowserItemPicked(onChosenFileUri) } val requestPermissionLauncher: ManagedActivityResultLauncher = diff --git a/app/src/main/kotlin/com/wire/android/util/permission/OpenGalleryRequestFlow.kt b/app/src/main/kotlin/com/wire/android/util/permission/OpenGalleryRequestFlow.kt index 017d464ab58..5e907c6e4b4 100644 --- a/app/src/main/kotlin/com/wire/android/util/permission/OpenGalleryRequestFlow.kt +++ b/app/src/main/kotlin/com/wire/android/util/permission/OpenGalleryRequestFlow.kt @@ -18,9 +18,9 @@ package com.wire.android.util.permission -import android.net.Uri import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -35,17 +35,18 @@ import com.wire.android.util.extension.getActivity * @param onPermissionDenied action to be executed when the permissions is denied */ @Composable -fun rememberOpenGalleryFlow( - onGalleryItemPicked: (Uri) -> Unit, +fun rememberOpenGalleryFlow( + contract: ActivityResultContract, + onGalleryItemPicked: (T) -> Unit, onPermissionDenied: () -> Unit, onPermissionPermanentlyDenied: (type: PermissionDenialType) -> Unit, -): UseStorageRequestFlow { +): UseStorageRequestFlow { val context = LocalContext.current - val openGalleryLauncher: ManagedActivityResultLauncher = + val openGalleryLauncher: ManagedActivityResultLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent() + contract ) { onChosenPictureUri -> - onChosenPictureUri?.let { onGalleryItemPicked(it) } + onGalleryItemPicked(onChosenPictureUri) } val requestPermissionLauncher: ManagedActivityResultLauncher = diff --git a/app/src/main/kotlin/com/wire/android/util/permission/UseStorageRequestFlow.kt b/app/src/main/kotlin/com/wire/android/util/permission/UseStorageRequestFlow.kt index 7ed6c7d6c2c..689997ea97f 100644 --- a/app/src/main/kotlin/com/wire/android/util/permission/UseStorageRequestFlow.kt +++ b/app/src/main/kotlin/com/wire/android/util/permission/UseStorageRequestFlow.kt @@ -20,16 +20,15 @@ package com.wire.android.util.permission import android.Manifest.permission.READ_EXTERNAL_STORAGE import android.content.Context -import android.net.Uri import android.os.Build import androidx.activity.compose.ManagedActivityResultLauncher import androidx.appcompat.app.AppCompatActivity import com.wire.android.util.extension.checkPermission -class UseStorageRequestFlow( +class UseStorageRequestFlow( private val mimeType: String, private val context: Context, - private val browseStorageActivityLauncher: ManagedActivityResultLauncher, + private val browseStorageActivityLauncher: ManagedActivityResultLauncher, private val accessFilePermissionLauncher: ManagedActivityResultLauncher ) { fun launch() { diff --git a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt index ee32413b1ee..17366c35b4b 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt @@ -20,6 +20,7 @@ package com.wire.android.util.ui import android.content.Context import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -137,7 +138,7 @@ class WireSessionImageLoader( drawableResultWrapper = DrawableResultWrapper(), ) ) - if (SDK_INT >= 28) { + if (SDK_INT >= VERSION_CODES.P) { add(ImageDecoderDecoder.Factory()) } else { add(GifDecoder.Factory()) diff --git a/app/src/main/res/drawable/ic_attention.xml b/app/src/main/res/drawable/ic_attention.xml new file mode 100644 index 00000000000..7fb97ada215 --- /dev/null +++ b/app/src/main/res/drawable/ic_attention.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/mock_message_image.png b/app/src/main/res/drawable/mock_message_image.png deleted file mode 100644 index 9ab60388a78..00000000000 Binary files a/app/src/main/res/drawable/mock_message_image.png and /dev/null differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a56960581c2..b6fccded123 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1226,9 +1226,11 @@ Video could not be sent Image could not be sent File could not be sent + Assets could not be sent You can only share a video up to %d MB. You can only share an image up to %d MB. You can only share a file up to %d MB. + Remove all marked assets and try again The file was saved to your device. Self-deleting message • %1$s @@ -1431,4 +1433,9 @@ Allow Wire to access your device location to send your location. Please wait... Location could not be shared + + + Remove asset + Asset attention + diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index 87f0bb281a0..0c4cd6c357e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -23,9 +23,12 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.PingRinger +import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase +import com.wire.android.ui.navArgs import com.wire.android.util.ImageUtil import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.sync.SyncState import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageResult import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase @@ -55,11 +58,15 @@ import okio.buffer internal class SendMessageViewModelArrangement { + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + init { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - + every { savedStateHandle.navArgs() } returns ConversationNavArgs( + conversationId = conversationId + ) // Default empty values coEvery { observeOngoingCallsUseCase() } returns flowOf(listOf()) coEvery { observeEstablishedCallsUseCase() } returns flowOf(listOf()) @@ -149,7 +156,8 @@ internal class SendMessageViewModelArrangement { setNotifiedAboutConversationUnderLegalHold = setNotifiedAboutConversationUnderLegalHold, observeConversationUnderLegalHoldNotified = observeConversationUnderLegalHoldNotified, sendLocation = sendLocation, - removeMessageDraft = removeMessageDraftUseCase + removeMessageDraft = removeMessageDraftUseCase, + savedStateHandle = savedStateHandle ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt index 1496dbd1de3..b3694b310f5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt @@ -169,7 +169,7 @@ class SendMessageViewModelTest { .withSuccessfulSendAttachmentMessage() .withHandleUriAsset(HandleUriAssetUseCase.Result.Failure.AssetTooLarge(mockedAttachment, 25)) .arrange() - val mockedMessageBundle = ComposableMessageBundle.AttachmentPickedBundle( + val mockedMessageBundle = ComposableMessageBundle.UriPickedBundle( conversationId = conversationId, attachmentUri = UriAsset("mocked_image.jpeg".toUri(), false) ) @@ -211,7 +211,7 @@ class SendMessageViewModelTest { .withSuccessfulSendAttachmentMessage() .withHandleUriAsset(HandleUriAssetUseCase.Result.Failure.AssetTooLarge(mockedAttachment, limit)) .arrange() - val mockedMessageBundle = ComposableMessageBundle.AttachmentPickedBundle( + val mockedMessageBundle = ComposableMessageBundle.UriPickedBundle( conversationId = conversationId, attachmentUri = UriAsset("mocked_image.jpeg".toUri(), false) ) @@ -244,7 +244,7 @@ class SendMessageViewModelTest { .withSuccessfulSendAttachmentMessage() .withHandleUriAsset(HandleUriAssetUseCase.Result.Failure.Unknown) .arrange() - val mockedMessageBundle = ComposableMessageBundle.AttachmentPickedBundle( + val mockedMessageBundle = ComposableMessageBundle.UriPickedBundle( conversationId = conversationId, attachmentUri = UriAsset("mocked_image.jpeg".toUri(), false) ) @@ -419,7 +419,7 @@ class SendMessageViewModelTest { ) } assertEquals( - SureAboutMessagingDialogState.Visible.ConversationVerificationDegraded(messageBundle), + SureAboutMessagingDialogState.Visible.ConversationVerificationDegraded(conversationId, listOf(messageBundle)), viewModel.sureAboutMessagingDialogState ) } @@ -438,7 +438,7 @@ class SendMessageViewModelTest { // then coVerify(exactly = 0) { arrangement.sendTextMessage.invoke(any(), any(), any(), any()) } assertEquals( - SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.BeforeSending(messageBundle), + SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.BeforeSending(conversationId, listOf(messageBundle)), viewModel.sureAboutMessagingDialogState ) } @@ -497,7 +497,7 @@ class SendMessageViewModelTest { // then coVerify(exactly = 0) { arrangement.retryFailedMessageUseCase.invoke(eq(messageId), any()) } assertEquals( - SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.AfterSending(messageId, conversationId), + SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold.AfterSending(conversationId, listOf(messageId)), viewModel.sureAboutMessagingDialogState ) } diff --git a/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt b/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt index 800829bb620..708462915b1 100644 --- a/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt +++ b/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt @@ -88,6 +88,7 @@ private fun CommonExtension<*, *, *, *, *>.configureLint(project: Project) { disable.add("IconDensities") // For testing purpose. This is safe to remove. disable.add("IconMissingDensityFolder") // For testing purpose. This is safe to remove. disable.add("ComposePreviewPublic") // Needed for screenshot testing. + disable.add("MissingTranslation") // translations are added asynchronously baseline = project.file("lint-baseline.xml") } diff --git a/core/ui-common/build.gradle.kts b/core/ui-common/build.gradle.kts index b01cc651ee2..fe76381d5ea 100644 --- a/core/ui-common/build.gradle.kts +++ b/core/ui-common/build.gradle.kts @@ -29,6 +29,11 @@ dependencies { implementation(libs.accompanist.systemUI) implementation(libs.visibilityModifiers) + // Image loading + implementation(libs.coil.core) + implementation(libs.coil.gif) + implementation(libs.coil.compose) + testImplementation(libs.junit4) androidTestImplementation(libs.androidx.test.extJunit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/error/ErrorIcon.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/error/ErrorIcon.kt new file mode 100644 index 00000000000..2fa4dd4c1f5 --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/error/ErrorIcon.kt @@ -0,0 +1,47 @@ +/* + * 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.common.error + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import com.wire.android.ui.common.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireColorScheme + +@Composable +fun ErrorIcon( + contentDescription: String, + modifier: Modifier = Modifier +) { + Icon( + modifier = modifier + .clip(shape = RoundedCornerShape(dimensions().spacing4x)) + .background(color = MaterialTheme.wireColorScheme.error) + .padding(dimensions().spacing6x), + painter = painterResource(id = R.drawable.ic_attention), + contentDescription = contentDescription, + tint = MaterialTheme.wireColorScheme.onError + ) +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/image/WireImage.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/image/WireImage.kt new file mode 100644 index 00000000000..b73c4c330d7 --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/image/WireImage.kt @@ -0,0 +1,75 @@ +/* + * 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.common.image + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImage +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.request.ImageRequest +import com.wire.android.ui.common.R +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.theme.WireTheme + +@Composable +fun WireImage( + model: Any?, + contentDescription: String, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + placeholder: Painter? = null +) { + AsyncImage( + modifier = modifier, + placeholder = if (LocalInspectionMode.current) { + painterResource(R.drawable.mock_image) + } else { + placeholder + }, + model = ImageRequest.Builder(LocalContext.current) + .data(model) + .decoderFactory( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoderDecoder.Factory() + } else { + GifDecoder.Factory() + } + ) + .build(), + contentDescription = contentDescription, + contentScale = contentScale + ) +} + +@MultipleThemePreviews +@Composable +fun PreviewWireImage() { + WireTheme { + WireImage( + model = null, + contentDescription = "preview" + ) + } +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/preview/MultipleThemePreviews.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/preview/MultipleThemePreviews.kt new file mode 100644 index 00000000000..b68b1cbefee --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/preview/MultipleThemePreviews.kt @@ -0,0 +1,49 @@ +/* + * 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.common.preview + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.ui.theme.WireColorScheme +import com.wire.android.ui.theme.WireTheme + +@Preview( + name = "Dark theme", + showBackground = true, + backgroundColor = 0xFF17181A, + uiMode = UI_MODE_NIGHT_YES +) +@Preview( + name = "Light theme", + showBackground = true, + backgroundColor = 0xFFEDEFF0, + uiMode = UI_MODE_NIGHT_NO +) +/** + * Helper annotation that adds a preview for Light and Dark theme previews, _i.e._ + * with [Preview.uiMode] set to [UI_MODE_NIGHT_NO] and [UI_MODE_NIGHT_YES]. + * It has hardcoded background colors following the [WireColorScheme]. + * + * **Important** + * + * Just like regular [Preview] annotations, it's important that the composable + * preview is actually reactive to the change in theme. So it might be necessary + * to wrap the preview in a [WireTheme] block. + */ +annotation class MultipleThemePreviews diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/remove/RemoveIcon.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/remove/RemoveIcon.kt new file mode 100644 index 00000000000..5d9259ba7f9 --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/remove/RemoveIcon.kt @@ -0,0 +1,54 @@ +/* + * 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.common.remove + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import com.wire.android.ui.common.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireColorScheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RemoveIcon( + contentDescription: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Icon( + modifier = modifier + .size(dimensions().spacing24x) + .combinedClickable(onClick = onClick) + .clip(shape = CircleShape) + .background(color = MaterialTheme.wireColorScheme.inverseSurface) + .padding(dimensions().spacing6x), + painter = painterResource(id = R.drawable.ic_close), + contentDescription = contentDescription, + tint = MaterialTheme.wireColorScheme.inverseOnSurface + ) +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index ed4b93454a6..48fd8a2ecb5 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -149,6 +149,7 @@ data class WireDimensions( val spacing72x: Dp, val spacing80x: Dp, val spacing100x: Dp, + val spacing120x: Dp, val spacing200x: Dp, // Corners val corner2x: Dp, @@ -302,6 +303,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( spacing72x = 72.dp, spacing80x = 80.dp, spacing100x = 100.dp, + spacing120x = 120.dp, spacing200x = 200.dp, corner2x = 2.dp, corner4x = 4.dp, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt index 884a3dec3a9..e4a496eee3d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt @@ -16,6 +16,8 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("ParameterListWrapping") + package com.wire.android.ui.theme import androidx.compose.material3.Typography @@ -25,7 +27,7 @@ import io.github.esentsov.PackagePrivate @Immutable data class WireTypography( - val title01: TextStyle, val title02: TextStyle, val title03: TextStyle, val title04: TextStyle, + val title01: TextStyle, val title02: TextStyle, val title03: TextStyle, val title04: TextStyle, val title05: TextStyle, val body01: TextStyle, val body02: TextStyle, val body03: TextStyle, val body04: TextStyle, val body05: TextStyle, val button01: TextStyle, val button02: TextStyle, val button03: TextStyle, val button04: TextStyle, val button05: TextStyle, val label01: TextStyle, val label02: TextStyle, val label03: TextStyle, val label04: TextStyle, val label05: TextStyle, @@ -45,6 +47,7 @@ private val DefaultWireTypography = WireTypography( title02 = WireTypographyBase.Title02, title03 = WireTypographyBase.Title03, title04 = WireTypographyBase.Title04, + title05 = WireTypographyBase.Title05, body01 = WireTypographyBase.Body01, body02 = WireTypographyBase.Body02, body03 = WireTypographyBase.Body03, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt index bc9d9d44739..0a12f33559e 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt @@ -49,6 +49,9 @@ object WireTypographyBase { val Title04 = Title01.copy( fontWeight = FontWeight.W400, ) + val Title05 = Title01.copy( + fontWeight = FontWeight.W900 + ) val Body01 = TextStyle( fontWeight = FontWeight.W400, fontSize = 15.sp, diff --git a/core/ui-common/src/main/res/drawable/ic_attention.xml b/core/ui-common/src/main/res/drawable/ic_attention.xml new file mode 100644 index 00000000000..7fb97ada215 --- /dev/null +++ b/core/ui-common/src/main/res/drawable/ic_attention.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui-common/src/main/res/drawable/ic_close.xml b/core/ui-common/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000000..7c4f2a4289e --- /dev/null +++ b/core/ui-common/src/main/res/drawable/ic_close.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/core/ui-common/src/main/res/drawable/mock_image.jpeg b/core/ui-common/src/main/res/drawable/mock_image.jpeg new file mode 100644 index 00000000000..022538228ca Binary files /dev/null and b/core/ui-common/src/main/res/drawable/mock_image.jpeg differ