diff --git a/Jetchat/app/build.gradle.kts b/Jetchat/app/build.gradle.kts index 28b482d11a..f02d7e7085 100644 --- a/Jetchat/app/build.gradle.kts +++ b/Jetchat/app/build.gradle.kts @@ -111,6 +111,9 @@ dependencies { implementation(libs.androidx.compose.ui.viewbinding) implementation(libs.androidx.compose.ui.googlefonts) + implementation(libs.coil.kt.compose) + implementation(libs.coil.kt.gif) + debugImplementation(libs.androidx.compose.ui.test.manifest) androidTestImplementation(libs.junit) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 283e7f8b7f..61f79124b2 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -18,13 +18,16 @@ package com.example.compose.jetchat.conversation -import android.content.ClipDescription +import android.net.Uri import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.content.ReceiveContentListener +import androidx.compose.foundation.content.TransferableContent +import androidx.compose.foundation.content.consume +import androidx.compose.foundation.content.contentReceiver import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -48,6 +51,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Search @@ -72,15 +76,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropEvent -import androidx.compose.ui.draganddrop.DragAndDropTarget -import androidx.compose.ui.draganddrop.mimeTypes -import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag @@ -89,6 +90,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import coil3.compose.AsyncImage +import coil3.request.ImageRequest import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R import com.example.compose.jetchat.components.JetchatAppBar @@ -128,41 +132,40 @@ fun ConversationContent( mutableStateOf(Color.Transparent) } - val dragAndDropCallback = remember { - object : DragAndDropTarget { - override fun onDrop(event: DragAndDropEvent): Boolean { - val clipData = event.toAndroidDragEvent().clipData + val textFieldState = rememberTextFieldState() + var contentReceiverImages by remember { mutableStateOf>(emptyList()) } - if (clipData.itemCount < 1) { - return false - } - - uiState.addMessage( - Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow) - ) - - return true - } - - override fun onStarted(event: DragAndDropEvent) { - super.onStarted(event) - borderStroke = Color.Red + val receiveContentListener = remember { + object : ReceiveContentListener { + override fun onDragEnd() { + background = Color.Transparent + borderStroke = Color.Transparent } - override fun onEntered(event: DragAndDropEvent) { - super.onEntered(event) + override fun onDragEnter() { background = Color.Red.copy(alpha = .3f) } - override fun onExited(event: DragAndDropEvent) { - super.onExited(event) + override fun onDragExit() { background = Color.Transparent } - override fun onEnded(event: DragAndDropEvent) { - super.onEnded(event) - background = Color.Transparent - borderStroke = Color.Transparent + override fun onDragStart() { + borderStroke = Color.Red + } + + override fun onReceive(transferableContent: TransferableContent): TransferableContent? { + return transferableContent.consume { clipDataItem -> + if (!clipDataItem.text.isNullOrBlank()) { + textFieldState.addText(clipDataItem.text?.toString() ?: "") + } + if (clipDataItem.uri != null) { + contentReceiverImages += clipDataItem.uri + true + } else { + false + } + } } } } @@ -187,13 +190,7 @@ fun ConversationContent( Modifier.fillMaxSize().padding(paddingValues) .background(color = background) .border(width = 2.dp, color = borderStroke) - .dragAndDropTarget(shouldStartDragAndDrop = { event -> - event - .mimeTypes() - .contains( - ClipDescription.MIMETYPE_TEXT_PLAIN - ) - }, target = dragAndDropCallback) + .contentReceiver(receiveContentListener) ) { Messages( messages = uiState.messages, @@ -202,16 +199,36 @@ fun ConversationContent( scrollState = scrollState ) UserInput( + textFieldState = textFieldState, onMessageSent = { content -> + // first send every image as a separate message + contentReceiverImages.fastForEach { + uiState.addMessage( + Message( + author = authorMe, + content = "", + timestamp = timeNow, + image = it + ) + ) + } + // finally send the text content uiState.addMessage( Message(authorMe, content, timeNow) ) + contentReceiverImages = emptyList() }, resetScroll = { scope.launch { scrollState.scrollToItem(0) } }, + images = contentReceiverImages, + onClearImage = { removeIndex -> + contentReceiverImages = contentReceiverImages.filterIndexed { index, _ -> + index != removeIndex + } + }, // let this element handle the padding so that the elevation is shown behind the // navigation bar modifier = Modifier.navigationBarsPadding().imePadding() @@ -491,15 +508,17 @@ fun ChatItemBubble( } Column { - Surface( - color = backgroundBubbleColor, - shape = ChatBubbleShape - ) { - ClickableMessage( - message = message, - isUserMe = isUserMe, - authorClicked = authorClicked - ) + if (message.content.isNotBlank()) { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape + ) { + ClickableMessage( + message = message, + isUserMe = isUserMe, + authorClicked = authorClicked + ) + } } message.image?.let { @@ -508,11 +527,13 @@ fun ChatItemBubble( color = backgroundBubbleColor, shape = ChatBubbleShape ) { - Image( - painter = painterResource(it), + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(it) + .build(), + contentDescription = stringResource(id = R.string.attached_image), contentScale = ContentScale.Fit, - modifier = Modifier.size(160.dp), - contentDescription = stringResource(id = R.string.attached_image) + modifier = Modifier.size(160.dp) ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt index edd61c738d..252ac01a3d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -38,6 +38,6 @@ data class Message( val author: String, val content: String, val timestamp: String, - val image: Int? = null, - val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else, + val image: Any? = null, + val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index cb28308976..eaaebeedd7 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -38,6 +38,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -54,10 +56,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.outlined.AlternateEmail import androidx.compose.material.icons.outlined.Duo import androidx.compose.material.icons.outlined.InsertPhoto @@ -94,7 +100,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.SemanticsPropertyKey @@ -104,17 +112,19 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import coil3.compose.AsyncImage +import coil3.request.ImageRequest import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R +import kotlinx.coroutines.delay import kotlin.math.absoluteValue import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay enum class InputSelector { NONE, @@ -133,15 +143,21 @@ enum class EmojiStickerSelector { @Preview @Composable fun UserInputPreview() { - UserInput(onMessageSent = {}) + UserInput( + textFieldState = rememberTextFieldState(), + onMessageSent = {} + ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun UserInput( + textFieldState: TextFieldState, onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}, + images: List = emptyList(), + onClearImage: (Int) -> Unit = {} ) { var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) } val dismissKeyboard = { currentInputSelector = InputSelector.NONE } @@ -151,18 +167,20 @@ fun UserInput( BackHandler(onBack = dismissKeyboard) } - var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } - // Used to decide if the keyboard should be shown var textFieldFocusState by remember { mutableStateOf(false) } Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) { Column(modifier = modifier) { + val sendMessageEnabled = textFieldState.text.isNotBlank() || images.isNotEmpty() + AnimatedVisibility(images.isNotEmpty()) { + SelectedImages( + images = images, + onClearImage = onClearImage + ) + } UserInputText( - textFieldValue = textState, - onTextChanged = { textState = it }, + textFieldState = textFieldState, // Only show the keyboard if there's no input selector and text field has focus keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, // Close extended selector if text field receives focus @@ -174,21 +192,23 @@ fun UserInput( textFieldFocusState = focused }, onMessageSent = { - onMessageSent(textState.text) - // Reset text field and close keyboard - textState = TextFieldValue() - // Move scroll to bottom - resetScroll() + if (sendMessageEnabled) { + onMessageSent(textFieldState.text.toString()) + // Reset text field and close keyboard + textFieldState.clearText() + // Move scroll to bottom + resetScroll() + } }, focusState = textFieldFocusState ) UserInputSelector( onSelectorChange = { currentInputSelector = it }, - sendMessageEnabled = textState.text.isNotBlank(), + sendMessageEnabled = sendMessageEnabled, onMessageSent = { - onMessageSent(textState.text) + onMessageSent(textFieldState.text.toString()) // Reset text field and close keyboard - textState = TextFieldValue() + textFieldState.clearText() // Move scroll to bottom resetScroll() dismissKeyboard() @@ -197,25 +217,55 @@ fun UserInput( ) SelectorExpanded( onCloseRequested = dismissKeyboard, - onTextAdded = { textState = textState.addText(it) }, + onTextAdded = { textFieldState.addText(it) }, currentSelector = currentInputSelector ) } } } -private fun TextFieldValue.addText(newString: String): TextFieldValue { - val newText = this.text.replaceRange( - this.selection.start, - this.selection.end, - newString - ) - val newSelection = TextRange( - start = newText.length, - end = newText.length - ) +internal fun TextFieldState.addText(newString: String) { + edit { + replace(selection.min, selection.max, newString) + selection = TextRange(length) + } +} - return this.copy(text = newText, selection = newSelection) +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SelectedImages( + images: List, + onClearImage: (Int) -> Unit, + modifier: Modifier = Modifier +) { + FlowRow( + modifier = modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + images.fastForEachIndexed { index, image -> + Box(modifier = Modifier.size(72.dp)) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(image) + .build(), + contentDescription = stringResource(id = R.string.attached_image), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .align(Alignment.BottomStart) + ) + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(R.string.remove_image), + modifier = Modifier + .align(Alignment.TopEnd) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .clickable { onClearImage(index) } + ) + } + } + } } @Composable @@ -405,9 +455,8 @@ var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey @ExperimentalFoundationApi @Composable private fun UserInputText( + textFieldState: TextFieldState, keyboardType: KeyboardType = KeyboardType.Text, - onTextChanged: (TextFieldValue) -> Unit, - textFieldValue: TextFieldValue, keyboardShown: Boolean, onTextFieldFocused: (Boolean) -> Unit, onMessageSent: (String) -> Unit, @@ -434,16 +483,17 @@ private fun UserInputText( RecordingIndicator { swipeOffset.value } } else { UserInputTextField( - textFieldValue, - onTextChanged, + textFieldState, onTextFieldFocused, keyboardType, focusState, onMessageSent, - Modifier.fillMaxWidth().semantics { - contentDescription = a11ylabel - keyboardShownProperty = keyboardShown - } + Modifier + .fillMaxWidth() + .semantics { + contentDescription = a11ylabel + keyboardShownProperty = keyboardShown + } ) } } @@ -471,8 +521,7 @@ private fun UserInputText( @Composable private fun BoxScope.UserInputTextField( - textFieldValue: TextFieldValue, - onTextChanged: (TextFieldValue) -> Unit, + textFieldState: TextFieldState, onTextFieldFocused: (Boolean) -> Unit, keyboardType: KeyboardType, focusState: Boolean, @@ -481,8 +530,7 @@ private fun BoxScope.UserInputTextField( ) { var lastFocusState by remember { mutableStateOf(false) } BasicTextField( - value = textFieldValue, - onValueChange = { onTextChanged(it) }, + state = textFieldState, modifier = modifier .padding(start = 32.dp) .align(Alignment.CenterStart) @@ -496,17 +544,17 @@ private fun BoxScope.UserInputTextField( keyboardType = keyboardType, imeAction = ImeAction.Send ), - keyboardActions = KeyboardActions { - if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text) + onKeyboardAction = { + onMessageSent(textFieldState.text.toString()) }, - maxLines = 1, + lineLimits = TextFieldLineLimits.SingleLine, cursorBrush = SolidColor(LocalContentColor.current), textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current) ) val disableContentColor = MaterialTheme.colorScheme.onSurfaceVariant - if (textFieldValue.text.isEmpty() && !focusState) { + if (textFieldState.text.isEmpty() && !focusState) { Text( modifier = Modifier .align(Alignment.CenterStart) diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index bc04038a7a..9aaec9e61f 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Functionality currently not available Grab a beverage and check back later! Attached image + Remove image Search Information More options diff --git a/Jetchat/gradle/libs.versions.toml b/Jetchat/gradle/libs.versions.toml index 29300201d3..249a2e2f4f 100644 --- a/Jetchat/gradle/libs.versions.toml +++ b/Jetchat/gradle/libs.versions.toml @@ -27,7 +27,7 @@ androidx-wear-compose = "1.4.1" androidx-window = "1.3.0" androidxHiltNavigationCompose = "1.2.0" androix-test-uiautomator = "2.3.0" -coil = "2.7.0" +coil = "3.1.0" # @keep compileSdk = "35" coroutines = "1.10.1" @@ -123,7 +123,8 @@ androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-nav androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" } androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } -coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +coil-kt-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-kt-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" } dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } googlemaps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }