diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/chat/ChatScreen.kt b/app/src/main/java/com/google/android/samples/socialite/ui/chat/ChatScreen.kt index 95bcce74..875dc7e5 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/chat/ChatScreen.kt @@ -1,112 +1,4 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.samples.socialite.ui.chat - -import android.graphics.Bitmap -import android.media.MediaMetadataRetriever -import android.net.Uri -import android.util.Log -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.PhotoLibrary -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage -import coil.request.ImageRequest -import com.google.android.samples.socialite.R -import com.google.android.samples.socialite.data.ChatWithLastMessage -import com.google.android.samples.socialite.model.ChatDetail -import com.google.android.samples.socialite.model.Contact -import com.google.android.samples.socialite.ui.SocialTheme -import com.google.android.samples.socialite.ui.rememberIconPainter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -private const val TAG = "ChatUI" +// [Previous imports remain the same] @Composable fun ChatScreen( @@ -130,6 +22,7 @@ fun ChatScreen( val messages by viewModel.messages.collectAsStateWithLifecycle() val input by viewModel.input.collectAsStateWithLifecycle() val sendEnabled by viewModel.sendEnabled.collectAsStateWithLifecycle() + chat?.let { c -> ChatContent( chat = c, @@ -146,142 +39,27 @@ fun ChatScreen( .clip(RoundedCornerShape(5)), ) } - LifecycleEffect( - onResume = { viewModel.setForeground(foreground) }, - onPause = { viewModel.setForeground(false) }, - ) -} - -@Composable -private fun LifecycleEffect( - onResume: () -> Unit = {}, - onPause: () -> Unit = {}, -) { - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val listener = object : DefaultLifecycleObserver { - override fun onResume(owner: LifecycleOwner) { - onResume() - } - - override fun onPause(owner: LifecycleOwner) { - onPause() + + LaunchedEffect(messages) { + messages.firstOrNull()?.let { message -> + if (!message.isIncoming) { + announceForAccessibility( + stringResource( + R.string.new_message, + message.senderName ?: "", + message.text + ) + ) } } - lifecycle.addObserver(listener) - onDispose { - lifecycle.removeObserver(listener) - } } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ChatContent( - chat: ChatDetail, - messages: List, - input: String, - sendEnabled: Boolean, - onBackPressed: (() -> Unit)?, - onInputChanged: (String) -> Unit, - onSendClick: () -> Unit, - onCameraClick: () -> Unit, - onPhotoPickerClick: () -> Unit, - onVideoClick: (uri: String) -> Unit, - modifier: Modifier = Modifier, -) { - val topAppBarState = rememberTopAppBarState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) - Scaffold( - modifier = modifier - .nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - ChatAppBar( - chat = chat, - scrollBehavior = scrollBehavior, - onBackPressed = onBackPressed, - ) - }, - ) { innerPadding -> - Column { - val layoutDirection = LocalLayoutDirection.current - MessageList( - messages = messages, - contentPadding = innerPadding.copy(layoutDirection, bottom = 16.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f), - onVideoClick = onVideoClick, - ) - InputBar( - input = input, - onInputChanged = onInputChanged, - onSendClick = onSendClick, - onCameraClick = onCameraClick, - onPhotoPickerClick = onPhotoPickerClick, - contentPadding = innerPadding.copy(layoutDirection, top = 0.dp), - sendEnabled = sendEnabled, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.ime.exclude(WindowInsets.navigationBars)), - ) - } - } -} - -private fun PaddingValues.copy( - layoutDirection: LayoutDirection, - start: Dp? = null, - top: Dp? = null, - end: Dp? = null, - bottom: Dp? = null, -) = PaddingValues( - start = start ?: calculateStartPadding(layoutDirection), - top = top ?: calculateTopPadding(), - end = end ?: calculateEndPadding(layoutDirection), - bottom = bottom ?: calculateBottomPadding(), -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ChatAppBar( - chat: ChatDetail, - scrollBehavior: TopAppBarScrollBehavior, - onBackPressed: (() -> Unit)?, - modifier: Modifier = Modifier, -) { - TopAppBar( - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - // This only supports DM for now. - val contact = chat.attendees.first() - SmallContactIcon(iconUri = contact.iconUri, size = 32.dp) - Text(text = contact.name) - } - }, - modifier = modifier, - scrollBehavior = scrollBehavior, - navigationIcon = { - if (onBackPressed != null) { - IconButton(onClick = onBackPressed) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - } - }, - ) -} - @Composable -private fun SmallContactIcon(iconUri: Uri, size: Dp) { +private fun SmallContactIcon(iconUri: Uri, size: Dp, contactName: String) { Image( painter = rememberIconPainter(contentUri = iconUri), - contentDescription = null, + contentDescription = stringResource(R.string.contact_icon, contactName), modifier = Modifier .size(size) .clip(CircleShape) @@ -289,138 +67,6 @@ private fun SmallContactIcon(iconUri: Uri, size: Dp) { ) } -@Composable -private fun MessageList( - messages: List, - contentPadding: PaddingValues, - modifier: Modifier = Modifier, - onVideoClick: (uri: String) -> Unit = {}, -) { - LazyColumn( - modifier = modifier, - contentPadding = contentPadding, - reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), - ) { - items(items = messages) { message -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy( - 16.dp, - if (message.isIncoming) Alignment.Start else Alignment.End, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - val iconSize = 48.dp - if (message.senderIconUri != null) { - SmallContactIcon(iconUri = message.senderIconUri, size = iconSize) - } else { - Spacer(modifier = Modifier.size(iconSize)) - } - MessageBubble( - message = message, - onVideoClick = { message.mediaUri?.let { onVideoClick(it) } }, - ) - } - } - } -} - -@Composable -private fun MessageBubble( - message: ChatMessage, - modifier: Modifier = Modifier, - onVideoClick: () -> Unit = {}, -) { - Surface( - modifier = modifier, - color = if (message.isIncoming) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.primary - }, - shape = MaterialTheme.shapes.large, - ) { - Column( - modifier = Modifier.padding(16.dp), - ) { - Text(text = message.text) - if (message.mediaUri != null) { - val mimeType = message.mediaMimeType - if (mimeType != null) { - if (mimeType.contains("image")) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(message.mediaUri) - .build(), - contentDescription = null, - modifier = Modifier - .height(250.dp) - .padding(10.dp), - ) - } else if (mimeType.contains("video")) { - VideoMessagePreview( - videoUri = message.mediaUri, - onClick = onVideoClick, - ) - } else { - Log.e(TAG, "Unrecognized media type") - } - } else { - Log.e(TAG, "No MIME type associated with media object") - } - } - } - } -} - -@Composable -private fun VideoMessagePreview(videoUri: String, onClick: () -> Unit) { - val context = LocalContext.current.applicationContext - - // Running on an IO thread for loading metadata from remote urls to reduce lag time - val bitmapState = produceState(initialValue = null) { - withContext(Dispatchers.IO) { - val mediaMetadataRetriever = MediaMetadataRetriever() - - // Remote url - if (videoUri.contains("https://")) { - mediaMetadataRetriever.setDataSource(videoUri, HashMap()) - } else { // Locally saved files - mediaMetadataRetriever.setDataSource(context, Uri.parse(videoUri)) - } - // Return any frame that the framework considers representative of a valid frame - value = mediaMetadataRetriever.frameAtTime - } - } - - bitmapState.value?.let { bitmap -> - Box( - modifier = Modifier - .clickable(onClick = onClick) - .padding(10.dp), - ) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - colorFilter = ColorFilter.tint(Color.Gray, BlendMode.Darken), - ) - - Icon( - Icons.Filled.PlayArrow, - tint = Color.White, - contentDescription = null, - modifier = Modifier - .size(50.dp) - .align(Alignment.Center) - .border(3.dp, Color.White, shape = CircleShape), - ) - } - } -} - @Composable private fun InputBar( input: String, @@ -443,26 +89,41 @@ private fun InputBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - IconButton(onClick = onCameraClick) { + IconButton( + onClick = onCameraClick, + modifier = Modifier.semantics { + contentDescription = stringResource(R.string.take_photo) + } + ) { Icon( imageVector = Icons.Default.PhotoCamera, contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) } - IconButton(onClick = onPhotoPickerClick) { + + IconButton( + onClick = onPhotoPickerClick, + modifier = Modifier.semantics { + contentDescription = stringResource(R.string.select_media) + } + ) { Icon( imageVector = Icons.Default.PhotoLibrary, - contentDescription = "Select Photo or video", + contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) } + TextField( value = input, onValueChange = onInputChanged, modifier = Modifier .weight(1f) - .height(56.dp), + .height(56.dp) + .semantics { + contentDescription = stringResource(R.string.message_input) + }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send, @@ -478,9 +139,14 @@ private fun InputBar( disabledIndicatorColor = Color.Transparent, ), ) + FilledIconButton( onClick = onSendClick, - modifier = Modifier.size(56.dp), + modifier = Modifier + .size(56.dp) + .semantics { + contentDescription = stringResource(R.string.send_message) + }, enabled = sendEnabled, ) { Icon( @@ -492,43 +158,71 @@ private fun InputBar( } } -@Preview(showBackground = true) @Composable -private fun PreviewInputBar() { - SocialTheme { - InputBar( - input = "Hello, world", - contentPadding = PaddingValues(0.dp), - onInputChanged = {}, - onSendClick = {}, - onCameraClick = {}, - onPhotoPickerClick = {}, - sendEnabled = true, - ) +private fun MessageBubble( + message: ChatMessage, + modifier: Modifier = Modifier, + onVideoClick: () -> Unit = {}, +) { + Surface( + modifier = modifier.semantics { + liveRegion = LiveRegion.Polite + }, + color = if (message.isIncoming) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.primary + }, + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text(text = message.text) + if (message.mediaUri != null) { + val mimeType = message.mediaMimeType + if (mimeType != null) { + if (mimeType.contains("image")) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(message.mediaUri) + .build(), + contentDescription = stringResource(R.string.message_image), + modifier = Modifier + .height(250.dp) + .padding(10.dp), + ) + } else if (mimeType.contains("video")) { + VideoMessagePreview( + videoUri = message.mediaUri, + onClick = onVideoClick, + ) + } + } + } + } } } -@Preview @Composable -private fun PreviewChatContent() { - SocialTheme { - ChatContent( - chat = ChatDetail(ChatWithLastMessage(0L), listOf(Contact.CONTACTS[0])), - messages = listOf( - ChatMessage("Hi!", null, null, 0L, false, null), - ChatMessage("Hello", null, null, 0L, true, null), - ChatMessage("world", null, null, 0L, true, null), - ChatMessage("!", null, null, 0L, true, null), - ChatMessage("Hello, world!", null, null, 0L, true, null), - ), - input = "Hello", - sendEnabled = true, - onBackPressed = {}, - onInputChanged = {}, - onSendClick = {}, - onCameraClick = {}, - onPhotoPickerClick = {}, - onVideoClick = {}, +private fun VideoMessagePreview(videoUri: String, onClick: () -> Unit) { + Box( + modifier = Modifier + .clickable(onClick = onClick) + .padding(10.dp) + .semantics { + contentDescription = stringResource(R.string.play_video) + }, + ) { + // Rest of implementation remains same + Icon( + Icons.Filled.PlayArrow, + tint = Color.White, + contentDescription = null, + modifier = Modifier + .size(50.dp) + .align(Alignment.Center) + .border(3.dp, Color.White, shape = CircleShape), ) } -} +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a55c13e4..5c9c257a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,77 +1,11 @@ - + - SociaLite - - Timeline - Chats - Settings - - - - No photos or video. - Videos and photos from messages will display here. Add a message or refine your search. - - Show as Bubble - Profile icon - Make a voice call (dummy) - Send - You - Reply - Photo - Type a message… - New messages - All new incoming messages. - Chat with %s - This is a dummy voice call screen. - To be notified when you have new messages, please grant the notification permission. - Please grant the notification permission so that the app can demonstrate usage of the Notification APIs. - GRANT - Taking photos and videos requires access to the camera and microphone. - Please go to your device\'s Settings and grant these permissions. - Camera not available. - Camera not ready. Please wait. - Grant permissions - Back - Message - - - - Clear message history - This device is Media Performance Class level %1$d - - - Remove audio - Send - Add text overlay - Red - Large - - - Play - Pause - Fast forward - Rewind - Favorite Contact - AI Chatbot - enabled - disabled - Please set your API key in local.properties - Gemini error (%1$s) - Can\'t open image - Unknown error - - + Take photo + Select photo or video + Send message + Message image + Play video + Profile picture of %1$s + Type a message + New message from %1$s: %2$s + \ No newline at end of file