From fd7afccbc4a3c6601f76dff3ad2412650c193c56 Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Mon, 21 Apr 2025 14:31:59 -0500 Subject: [PATCH] Follow up improvements - Added ComposePreviewUtils - Added ComposePreviewUtilsDao (both for previewing w/ dependencies) - Additional fixes Signed-off-by: rapterjet2004 --- .../application/NextcloudTalkApplication.kt | 2 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 30 ++- .../talk/chat/data/ChatMessageRepository.kt | 2 + .../network/OfflineFirstChatRepository.kt | 9 + .../talk/chat/viewmodels/ChatViewModel.kt | 4 + .../nextcloud/talk/ui/ComposeChatAdapter.kt | 206 ++++++++++++----- .../talk/ui/dialog/ContextChatCompose.kt | 14 ++ .../talk/utils/preview/ComposePreviewUtils.kt | 188 ++++++++++++++++ .../utils/preview/ComposePreviewUtilsDaos.kt | 208 ++++++++++++++++++ 9 files changed, 604 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt diff --git a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt index f875d6772..49830d929 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -33,7 +33,6 @@ import coil.decode.SvgDecoder import coil.memory.MemoryCache import coil.util.DebugLogger import com.nextcloud.talk.BuildConfig -import com.nextcloud.talk.filebrowser.webdav.DavUtils import com.nextcloud.talk.dagger.modules.BusModule import com.nextcloud.talk.dagger.modules.ContextModule import com.nextcloud.talk.dagger.modules.DaosModule @@ -43,6 +42,7 @@ import com.nextcloud.talk.dagger.modules.RepositoryModule import com.nextcloud.talk.dagger.modules.RestModule import com.nextcloud.talk.dagger.modules.UtilsModule import com.nextcloud.talk.dagger.modules.ViewModelModule +import com.nextcloud.talk.filebrowser.webdav.DavUtils import com.nextcloud.talk.jobs.AccountRemovalWorker import com.nextcloud.talk.jobs.CapabilitiesWorker import com.nextcloud.talk.jobs.SignalingSettingsWorker diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 6a5c26314..fba9d5248 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -153,6 +153,7 @@ import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.ui.PlaybackSpeedControl import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet +import com.nextcloud.talk.ui.dialog.ContextChatCompose import com.nextcloud.talk.ui.dialog.DateTimeCompose import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment import com.nextcloud.talk.ui.dialog.MessageActionsDialog @@ -208,6 +209,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -296,7 +298,33 @@ class ChatActivity : private val startMessageSearchForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { executeIfResultOk(it) { intent -> - onMessageSearchResult(intent) + runBlocking { + val id = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID) + id?.let { + val long = id.toLong() + val isSaved = chatViewModel.isMessageSaved(id.toLong()) + if (isSaved) { + onMessageSearchResult(intent) + } else { + binding.genericComposeView.apply { + val shouldDismiss = mutableStateOf(false) + setContent { + val bundle = bundleOf() + bundle.putString(BundleKeys.KEY_CREDENTIALS, credentials!!) + bundle.putString(BundleKeys.KEY_BASE_URL, conversationUser!!.baseUrl) + bundle.putString(KEY_ROOM_TOKEN, roomToken) + bundle.putString(BundleKeys.KEY_MESSAGE_ID, id) + bundle.putString( + BundleKeys.KEY_CONVERSATION_NAME, + currentConversation!!.displayName + ) + ContextChatCompose(bundle).GetDialogView(shouldDismiss, context) + } + } + Log.d("Julius", "Should open something else") + } + } + } } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt index 8e2e3f3ed..3c7b7cf20 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt @@ -76,6 +76,8 @@ interface ChatMessageRepository : LifecycleAwareManager { */ suspend fun getMessage(messageId: Long, bundle: Bundle): Flow + suspend fun checkIfMessageIsSaved(messageId: Long): Boolean + @Suppress("LongParameterList") suspend fun sendChatMessage( credentials: String, diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index 1fc1f9235..5e693604d 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -475,6 +475,15 @@ class OfflineFirstChatRepository @Inject constructor( .map(ChatMessageEntity::asModel) } + override suspend fun checkIfMessageIsSaved(messageId: Long): Boolean { + try { + chatDao.getChatMessageForConversation(internalConversationId, messageId) + return true + } catch (_: Exception) { + return false + } + } + @Suppress("UNCHECKED_CAST", "MagicNumber", "Detekt.TooGenericExceptionCaught") private fun getMessagesFromServer(bundle: Bundle): Pair>? { val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 6a63ecfe1..617e949cd 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -281,6 +281,10 @@ class ChatViewModel @Inject constructor( conversationRepository.getRoom(token) } + suspend fun isMessageSaved(messageId: Long): Boolean { + return chatRepository.checkIfMessageIsSaved(messageId) + } + fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) { Log.d(TAG, "Remote server ${conversationModel.remoteServer}") if (conversationModel.remoteServer.isNullOrEmpty()) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt index f342826f8..e473f0684 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt @@ -8,6 +8,7 @@ package com.nextcloud.talk.ui import android.content.Context +import android.content.ContextWrapper import android.util.Log import android.view.View.TEXT_ALIGNMENT_VIEW_START import android.view.ViewGroup.LayoutParams.MATCH_PARENT @@ -23,6 +24,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -46,6 +48,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -68,15 +71,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight 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.viewinterop.AndroidView import androidx.core.graphics.ColorUtils +import androidx.emoji2.widget.EmojiTextView import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import autodagger.AutoInjector @@ -84,6 +90,8 @@ import coil.compose.AsyncImage import com.elyeproj.loaderviewlibrary.LoaderImageView import com.elyeproj.loaderviewlibrary.LoaderTextView import com.nextcloud.talk.R +import com.nextcloud.talk.activities.MainActivity +import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder.Companion.KEY_MIMETYPE import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.chat.data.model.ChatMessage @@ -99,7 +107,9 @@ import com.nextcloud.talk.models.json.opengraph.Reference import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType import com.nextcloud.talk.utils.message.MessageUtils +import com.nextcloud.talk.utils.preview.ComposePreviewUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import org.osmdroid.config.Configuration @@ -116,41 +126,54 @@ import kotlin.random.Random @Suppress("FunctionNaming", "TooManyFunctions", "LongMethod", "StaticFieldLeak", "LargeClass") class ComposeChatAdapter( private var messagesJson: List? = null, - private var messageId: String? = null + private var messageId: String? = null, + private val utils: ComposePreviewUtils? = null ) { + interface PreviewAble { + val viewThemeUtils: ViewThemeUtils + val messageUtils: MessageUtils + val contactsViewModel: ContactsViewModel + val chatViewModel: ChatViewModel + val context: Context + val userManager: UserManager + } + @AutoInjector(NextcloudTalkApplication::class) - inner class ComposeChatAdapterViewModel : ViewModel() { + inner class ComposeChatAdapterViewModel : ViewModel(), PreviewAble { @Inject - lateinit var viewThemeUtils: ViewThemeUtils + override lateinit var viewThemeUtils: ViewThemeUtils @Inject - lateinit var messageUtils: MessageUtils + override lateinit var messageUtils: MessageUtils @Inject - lateinit var contactsViewModel: ContactsViewModel + override lateinit var contactsViewModel: ContactsViewModel @Inject - lateinit var chatViewModel: ChatViewModel + override lateinit var chatViewModel: ChatViewModel @Inject - lateinit var context: Context + override lateinit var context: Context @Inject - lateinit var userManager: UserManager - - val items = mutableStateListOf() + override lateinit var userManager: UserManager init { - sharedApplication!!.componentApplication.inject(this) + sharedApplication?.componentApplication?.inject(this) } - - val currentUser: User = userManager.currentUser.blockingGet() - val colorScheme = viewThemeUtils.getColorScheme(context) - val highEmphasisColorInt = context.resources.getColor(R.color.high_emphasis_text, null) } + inner class ComposeChatAdapterPreviewViewModel( + override val viewThemeUtils: ViewThemeUtils, + override val messageUtils: MessageUtils, + override val contactsViewModel: ContactsViewModel, + override val chatViewModel: ChatViewModel, + override val context: Context, + override val userManager: UserManager + ) : ViewModel(), PreviewAble + companion object { val TAG: String = ComposeChatAdapter::class.java.simpleName private val REGULAR_TEXT_SIZE = 16.sp @@ -173,21 +196,48 @@ class ComposeChatAdapter( private var incomingShape: RoundedCornerShape = RoundedCornerShape(2.dp, 20.dp, 20.dp, 20.dp) private var outgoingShape: RoundedCornerShape = RoundedCornerShape(20.dp, 2.dp, 20.dp, 20.dp) - private val viewModel = ComposeChatAdapterViewModel() + + val viewModel: PreviewAble = + if (utils != null) { + ComposeChatAdapterPreviewViewModel( + utils.viewThemeUtils, + utils.messageUtils, + utils.contactsViewModel, + utils.chatViewModel, + utils.context, + utils.userManager + ) + } else { + ComposeChatAdapterViewModel() + } + + val items = mutableStateListOf() + val currentUser: User = viewModel.userManager.currentUser.blockingGet() + val colorScheme = viewModel.viewThemeUtils.getColorScheme(viewModel.context) + val highEmphasisColorInt = viewModel.context.resources.getColor(R.color.high_emphasis_text, null) + + fun Context.findMainActivityOrNull(): MainActivity? { + var context = this + while (context is ContextWrapper) { + if (context is MainActivity) return context + context = context.baseContext + } + return null + } fun addMessages(messages: MutableList, append: Boolean) { if (messages.isEmpty()) return val processedMessages = messages.toMutableList() - if (viewModel.items.isNotEmpty()) { + if (items.isNotEmpty()) { if (append) { - processedMessages.add(viewModel.items.first()) + processedMessages.add(items.first()) } else { - processedMessages.add(viewModel.items.last()) + processedMessages.add(items.last()) } } - if (append) viewModel.items.addAll(processedMessages) else viewModel.items.addAll(0, processedMessages) + if (append) items.addAll(processedMessages) else items.addAll(0, processedMessages) } @OptIn(ExperimentalFoundationApi::class) @@ -202,7 +252,7 @@ class ComposeChatAdapter( modifier = Modifier.padding(16.dp) ) { stickyHeader { - if (viewModel.items.size == 0) { + if (items.size == 0) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -211,11 +261,11 @@ class ComposeChatAdapter( ShimmerGroup() } } else { - val timestamp = viewModel.items[listState.firstVisibleItemIndex].timestamp + val timestamp = items[listState.firstVisibleItemIndex].timestamp val dateString = formatTime(timestamp * LONG_1000) - val color = Color(viewModel.highEmphasisColorInt) + val color = Color(highEmphasisColorInt) val backgroundColor = - viewModel.context.resources.getColor(R.color.bg_message_list_incoming_bubble, null) + LocalContext.current.resources.getColor(R.color.bg_message_list_incoming_bubble, null) Row( horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically @@ -229,8 +279,8 @@ class ComposeChatAdapter( .padding(8.dp) .shadow( 16.dp, - spotColor = viewModel.colorScheme.primary, - ambientColor = viewModel.colorScheme.primary + spotColor = colorScheme.primary, + ambientColor = colorScheme.primary ) .background(color = Color(backgroundColor), shape = RoundedCornerShape(8.dp)) .padding(8.dp) @@ -240,8 +290,8 @@ class ComposeChatAdapter( } } - items(viewModel.items) { message -> - message.activeUser = viewModel.currentUser + items(items) { message -> + message.activeUser = currentUser when (val type = message.getCalculateMessageType()) { ChatMessage.MessageType.SYSTEM_MESSAGE -> { if (!message.shouldFilter()) { @@ -284,7 +334,7 @@ class ComposeChatAdapter( } } - if (messageId != null && viewModel.items.size > 0) { + if (messageId != null && items.size > 0) { LaunchedEffect(Dispatchers.Main) { delay(SCROLL_DELAY) val pos = searchMessages(messageId!!) @@ -326,7 +376,7 @@ class ComposeChatAdapter( } private fun searchMessages(searchId: String): Int { - viewModel.items.forEachIndexed { index, message -> + items.forEachIndexed { index, message -> if (message.id == searchId) return index } return -1 @@ -380,18 +430,18 @@ class ComposeChatAdapter( @Composable (RowScope.() -> Unit) ) { - val incoming = message.actorId != viewModel.currentUser.userId + val incoming = message.actorId != currentUser.userId val color = if (incoming) { if (message.isDeleted) { - viewModel.context.resources.getColor(R.color.bg_message_list_incoming_bubble_deleted, null) + LocalContext.current.resources.getColor(R.color.bg_message_list_incoming_bubble_deleted, null) } else { - viewModel.context.resources.getColor(R.color.bg_message_list_incoming_bubble, null) + LocalContext.current.resources.getColor(R.color.bg_message_list_incoming_bubble, null) } } else { if (message.isDeleted) { - ColorUtils.setAlphaComponent(viewModel.colorScheme.surfaceVariant.toArgb(), HALF_OPACITY) + ColorUtils.setAlphaComponent(colorScheme.surfaceVariant.toArgb(), HALF_OPACITY) } else { - viewModel.colorScheme.surfaceVariant.toArgb() + colorScheme.surfaceVariant.toArgb() } } val shape = if (incoming) incomingShape else outgoingShape @@ -405,7 +455,7 @@ class ComposeChatAdapter( if (incoming) { val imageUri = message.actorId?.let { viewModel.contactsViewModel.getImageUri(it, true) } val errorPlaceholderImage: Int = R.drawable.account_circle_96dp - val loadedImage = loadImage(imageUri, viewModel.context, errorPlaceholderImage) + val loadedImage = loadImage(imageUri, LocalContext.current, errorPlaceholderImage) AsyncImage( model = loadedImage, contentDescription = stringResource(R.string.user_avatar), @@ -427,13 +477,13 @@ class ComposeChatAdapter( color = Color(color), shape = shape ) { - val timeString = DateUtils(viewModel.context).getLocalTimeStringFromTimestamp(message.timestamp) + val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) val modifier = if (includePadding) Modifier.padding(8.dp, 4.dp, 8.dp, 4.dp) else Modifier Column(modifier = modifier) { if (message.parentMessageId != null && !message.isDeleted && messagesJson != null) { messagesJson!! .find { it.parentMessage?.id == message.parentMessageId } - ?.parentMessage!!.asModel().let { CommonMessageQuote(viewModel.context, it) } + ?.parentMessage!!.asModel().let { CommonMessageQuote(LocalContext.current, it) } } if (incoming) { @@ -470,8 +520,8 @@ class ComposeChatAdapter( private fun Modifier.withCustomAnimation(incoming: Boolean): Modifier { val infiniteTransition = rememberInfiniteTransition() val borderColor by infiniteTransition.animateColor( - initialValue = viewModel.colorScheme.primary, - targetValue = viewModel.colorScheme.background, + initialValue = colorScheme.primary, + targetValue = colorScheme.background, animationSpec = infiniteRepeatable( animation = tween(ANIMATED_BLINK, easing = LinearEasing), repeatMode = RepeatMode.Reverse @@ -542,7 +592,7 @@ class ComposeChatAdapter( LoaderTextView(ctx).apply { layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) val color = if (outgoing) { - viewModel.colorScheme.primary.toArgb() + colorScheme.primary.toArgb() } else { resources.getColor(R.color.nc_shimmer_default_color, null) } @@ -562,7 +612,7 @@ class ComposeChatAdapter( @Composable private fun EnrichedText(message: ChatMessage) { AndroidView(factory = { ctx -> - val incoming = message.actorId != viewModel.currentUser.userId + val incoming = message.actorId != currentUser.userId var processedMessageText = viewModel.messageUtils.enrichChatMessageText( ctx, message, @@ -574,7 +624,7 @@ class ComposeChatAdapter( ctx, viewModel.viewThemeUtils, processedMessageText!!, message, null ) - androidx.emoji2.widget.EmojiTextView(ctx).apply { + EmojiTextView(ctx).apply { layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) setLineSpacing(0F, LINE_SPACING) textAlignment = TEXT_ALIGNMENT_VIEW_START @@ -592,14 +642,14 @@ class ComposeChatAdapter( } @Composable - private fun SystemMessage(message: ChatMessage) { + fun SystemMessage(message: ChatMessage) { val similarMessages = sharedApplication!!.resources.getQuantityString( R.plurals.see_similar_system_messages, message.expandableChildrenAmount, message.expandableChildrenAmount ) Column(horizontalAlignment = Alignment.CenterHorizontally) { - val timeString = DateUtils(viewModel.context).getLocalTimeStringFromTimestamp(message.timestamp) + val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Modifier.weight(1f)) Text( @@ -632,7 +682,7 @@ class ComposeChatAdapter( Text( text, fontSize = AUTHOR_TEXT_SIZE, - color = Color(viewModel.highEmphasisColorInt) + color = Color(highEmphasisColorInt) ) } } @@ -640,14 +690,15 @@ class ComposeChatAdapter( @Composable private fun ImageMessage(message: ChatMessage, state: MutableState) { val hasCaption = (message.message != "{file}") - val incoming = message.actorId != viewModel.currentUser.userId - val timeString = DateUtils(viewModel.context).getLocalTimeStringFromTimestamp(message.timestamp) + val incoming = message.actorId != currentUser.userId + val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) CommonMessageBody(message, includePadding = false, playAnimation = state.value) { Column { - message.activeUser = viewModel.currentUser + message.activeUser = currentUser val imageUri = message.imageUrl - val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image - val loadedImage = load(imageUri, viewModel.context, errorPlaceholderImage) + val mimetype = message.selectedIndividualHashMap!![KEY_MIMETYPE] + val drawableResourceId = getDrawableResourceIdForMimeType(mimetype) + val loadedImage = load(imageUri, LocalContext.current, drawableResourceId) AsyncImage( model = loadedImage, @@ -717,8 +768,8 @@ class ComposeChatAdapter( WaveformSeekBar(ctx).apply { setWaveData(FloatArray(DEFAULT_WAVE_SIZE) { Random.nextFloat() }) // READ ONLY for now setColors( - viewModel.colorScheme.inversePrimary.toArgb(), - viewModel.colorScheme.onPrimaryContainer.toArgb() + colorScheme.inversePrimary.toArgb(), + colorScheme.onPrimaryContainer.toArgb() ) } }, @@ -793,8 +844,8 @@ class ComposeChatAdapter( private fun LinkMessage(message: ChatMessage, state: MutableState) { val color = colorResource(R.color.high_emphasis_text) viewModel.chatViewModel.getOpenGraph( - viewModel.currentUser.getCredentials(), - viewModel.currentUser.baseUrl!!, + currentUser.getCredentials(), + currentUser.baseUrl!!, message.extractedUrlToPreview!! ) CommonMessageBody(message, playAnimation = state.value) { @@ -828,7 +879,7 @@ class ComposeChatAdapter( it.link?.let { Text(it, fontSize = TIME_TEXT_SIZE) } it.thumb?.let { val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image - val loadedImage = loadImage(it, viewModel.context, errorPlaceholderImage) + val loadedImage = loadImage(it, LocalContext.current, errorPlaceholderImage) AsyncImage( model = loadedImage, contentDescription = stringResource(R.string.nc_sent_an_image), @@ -882,7 +933,7 @@ class ComposeChatAdapter( if (cardName?.isNotEmpty() == true) { val cardDescription = String.format( - viewModel.context.resources.getString(R.string.deck_card_description), + LocalContext.current.resources.getString(R.string.deck_card_description), stackName, boardName ) @@ -899,3 +950,44 @@ class ComposeChatAdapter( } } } + +@Preview(showBackground = true, widthDp = 380, heightDp = 800) +@Composable +fun AllMessageTypesPreview() { + val previewUtils = ComposePreviewUtils.getInstance(LocalContext.current) + val adapter = remember { ComposeChatAdapter(messagesJson = null, messageId = null, previewUtils) } + + val sampleMessages = remember { + listOf( + // Text Messages + ChatMessage().apply { + jsonMessageId = 1 + actorId = "user1" + message = "I love Nextcloud" + timestamp = System.currentTimeMillis() + actorDisplayName = "User1" + messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name + }, + ChatMessage().apply { + jsonMessageId = 2 + actorId = "user1_id" + message = "I love Nextcloud" + timestamp = System.currentTimeMillis() + actorDisplayName = "User2" + messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name + } + ) + } + + LaunchedEffect(sampleMessages) { // Use LaunchedEffect or similar to update state once + if (adapter.items.isEmpty()) { // Prevent adding multiple times on recomposition + adapter.addMessages(sampleMessages.toMutableList(), append = false) // Add messages + } + } + + MaterialTheme(colorScheme = adapter.colorScheme) { // Use the (potentially faked) color scheme + Box(modifier = Modifier.fillMaxSize()) { // Provide a container + adapter.GetView() // Call the main Composable + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ContextChatCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ContextChatCompose.kt index 990eefe3b..7bdfab0ef 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ContextChatCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/ContextChatCompose.kt @@ -7,7 +7,10 @@ package com.nextcloud.talk.ui.dialog +import android.app.Activity import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo import android.os.Bundle import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -94,6 +97,15 @@ class ContextChatCompose(val bundle: Bundle) { } } + private fun Context.requireActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + throw IllegalStateException("No activity was present but it is required.") + } + @Composable fun GetDialogView( shouldDismiss: MutableState, @@ -101,9 +113,11 @@ class ContextChatCompose(val bundle: Bundle) { contextViewModel: ContextChatComposeViewModel = ContextChatComposeViewModel() ) { if (shouldDismiss.value) { + context.requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED return } + context.requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED val colorScheme = contextViewModel.viewThemeUtils.getColorScheme(context) MaterialTheme(colorScheme) { Dialog( diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt new file mode 100644 index 000000000..177aa75d0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -0,0 +1,188 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils.preview + +import android.content.Context +import com.github.aurae.retrofit2.LoganSquareConverterFactory +import com.nextcloud.android.common.ui.color.ColorUtil +import com.nextcloud.android.common.ui.theme.MaterialSchemes +import com.nextcloud.android.common.ui.theme.utils.AndroidViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.DialogViewThemeUtils +import com.nextcloud.android.common.ui.theme.utils.MaterialViewThemeUtils +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.chat.data.ChatMessageRepository +import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager +import com.nextcloud.talk.chat.data.io.MediaRecorderManager +import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.chat.data.network.OfflineFirstChatRepository +import com.nextcloud.talk.chat.data.network.RetrofitChatNetwork +import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.contacts.ContactsRepository +import com.nextcloud.talk.contacts.ContactsRepositoryImpl +import com.nextcloud.talk.contacts.ContactsViewModel +import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository +import com.nextcloud.talk.conversationlist.data.network.ConversationsNetworkDataSource +import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository +import com.nextcloud.talk.conversationlist.data.network.RetrofitConversationsNetwork +import com.nextcloud.talk.data.database.dao.ChatBlocksDao +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.dao.ConversationsDao +import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.data.network.NetworkMonitorImpl +import com.nextcloud.talk.data.user.UsersDao +import com.nextcloud.talk.data.user.UsersRepository +import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.repositories.reactions.ReactionsRepository +import com.nextcloud.talk.repositories.reactions.ReactionsRepositoryImpl +import com.nextcloud.talk.ui.theme.MaterialSchemesProviderImpl +import com.nextcloud.talk.ui.theme.TalkSpecificViewThemeUtils +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.database.user.CurrentUserProviderImpl +import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew +import com.nextcloud.talk.utils.message.MessageUtils +import com.nextcloud.talk.utils.preferences.AppPreferences +import com.nextcloud.talk.utils.preferences.AppPreferencesImpl +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory + +/** + * TODO - basically a reimplementation of common dependencies for use in Previewing Advanced Compose Views + * It's a hard coded Dependency Injector + * + */ +class ComposePreviewUtils private constructor(context: Context) { + private val mContext = context + + companion object { + fun getInstance(context: Context) = ComposePreviewUtils(context) + val TAG: String = ComposePreviewUtils::class.java.simpleName + } + + @OptIn(ExperimentalCoroutinesApi::class) + val appPreferences: AppPreferences + get() = AppPreferencesImpl(mContext) + + val context: Context = mContext + + val userRepository: UsersRepository + get() = UsersRepositoryImpl(usersDao) + + val userManager: UserManager + get() = UserManager(userRepository) + + val userProvider: CurrentUserProviderNew + get() = CurrentUserProviderImpl(userManager) + + val colorUtil: ColorUtil + get() = ColorUtil(mContext) + + val materialScheme: MaterialSchemes + get() = MaterialSchemesProviderImpl(userProvider, colorUtil).getMaterialSchemesForCurrentUser() + + val viewThemeUtils: ViewThemeUtils + get() { + val android = AndroidViewThemeUtils(materialScheme, colorUtil) + val material = MaterialViewThemeUtils(materialScheme, colorUtil) + val androidx = AndroidXViewThemeUtils(materialScheme, android) + val talk = TalkSpecificViewThemeUtils(materialScheme, androidx) + val dialog = DialogViewThemeUtils(materialScheme) + return ViewThemeUtils(materialScheme, android, material, androidx, talk, dialog) + } + + val messageUtils: MessageUtils + get() = MessageUtils(mContext) + + val retrofit: Retrofit + get() { + val retrofitBuilder = Retrofit.Builder() + .client(OkHttpClient.Builder().build()) + .baseUrl("https://nextcloud.com") + .addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io())) + .addConverterFactory(LoganSquareConverterFactory.create()) + + return retrofitBuilder.build() + } + + val ncApi: NcApi + get() = retrofit.create(NcApi::class.java) + + val ncApiCoroutines: NcApiCoroutines + get() = retrofit.create(NcApiCoroutines::class.java) + + val chatNetworkDataSource: ChatNetworkDataSource + get() = RetrofitChatNetwork(ncApi, ncApiCoroutines) + + val usersDao: UsersDao + get() = DummyUserDaoImpl() + + val chatMessagesDao: ChatMessagesDao + get() = DummyChatMessagesDaoImpl() + + val chatBlocksDao: ChatBlocksDao + get() = DummyChatBlocksDaoImpl() + + val conversationsDao: ConversationsDao + get() = DummyConversationDaoImpl() + + val networkMonitor: NetworkMonitor + get() = NetworkMonitorImpl(mContext) + + val chatRepository: ChatMessageRepository + get() = OfflineFirstChatRepository( + chatMessagesDao, + chatBlocksDao, + chatNetworkDataSource, + networkMonitor, + userProvider + ) + + val conversationNetworkDataSource: ConversationsNetworkDataSource + get() = RetrofitConversationsNetwork(ncApi) + + val conversationRepository: OfflineConversationsRepository + get() = OfflineFirstConversationsRepository( + conversationsDao, + conversationNetworkDataSource, + chatNetworkDataSource, + networkMonitor, + userProvider + ) + + val reactionsRepository: ReactionsRepository + get() = ReactionsRepositoryImpl(ncApi, userProvider, chatMessagesDao) + + val mediaRecorderManager: MediaRecorderManager + get() = MediaRecorderManager() + + val audioFocusRequestManager: AudioFocusRequestManager + get() = AudioFocusRequestManager(mContext) + + val chatViewModel: ChatViewModel + get() = ChatViewModel( + appPreferences, + chatNetworkDataSource, + chatRepository, + conversationRepository, + reactionsRepository, + mediaRecorderManager, + audioFocusRequestManager, + userProvider + ) + + val contactsRepository: ContactsRepository + get() = ContactsRepositoryImpl(ncApiCoroutines, userProvider) + + val contactsViewModel: ContactsViewModel + get() = ContactsViewModel(contactsRepository) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt new file mode 100644 index 000000000..904706f49 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -0,0 +1,208 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils.preview + +import com.nextcloud.talk.data.database.dao.ChatBlocksDao +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.dao.ConversationsDao +import com.nextcloud.talk.data.database.model.ChatBlockEntity +import com.nextcloud.talk.data.database.model.ChatMessageEntity +import com.nextcloud.talk.data.database.model.ConversationEntity +import com.nextcloud.talk.data.user.UsersDao +import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.models.json.push.PushConfigurationState +import io.reactivex.Maybe +import io.reactivex.Observable +import io.reactivex.Single +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class DummyChatMessagesDaoImpl : ChatMessagesDao { + override fun getNewestMessageId(internalConversationId: String): Long = 0L + + override fun getMessagesForConversation(internalConversationId: String): Flow> = flowOf() + + override fun getTempMessagesForConversation(internalConversationId: String): Flow> = + flowOf() + + override fun getTempMessageForConversation( + internalConversationId: String, + referenceId: String + ): Flow = flowOf() + + override suspend fun upsertChatMessages(chatMessages: List) { /* */ } + + override suspend fun upsertChatMessage(chatMessage: ChatMessageEntity) { /* */ } + + override fun getChatMessageForConversation( + internalConversationId: String, + messageId: Long + ): Flow = flowOf() + + override fun deleteChatMessages(internalIds: List) { /* */ } + + override fun deleteTempChatMessages(internalConversationId: String, referenceIds: List) { /* */ } + + override fun updateChatMessage(message: ChatMessageEntity) { /* */ } + + override fun getMessagesFromIds(messageIds: List): Flow> = flowOf() + + override fun getMessagesForConversationSince( + internalConversationId: String, + messageId: Long + ): Flow> = flowOf() + + override fun getMessagesForConversationBefore( + internalConversationId: String, + messageId: Long, + limit: Int + ): Flow> = flowOf() + + override fun getMessagesForConversationBeforeAndEqual( + internalConversationId: String, + messageId: Long, + limit: Int + ): Flow> = flowOf() + + override fun getCountBetweenMessageIds( + internalConversationId: String, + oldestMessageId: Long, + newestMessageId: Long + ): Int = 0 + + override fun clearAllMessagesForUser(pattern: String) { /* */ } + + override fun deleteMessagesOlderThan(internalConversationId: String, messageId: Long) { /* */ } +} + +class DummyUserDaoImpl : UsersDao() { + private val dummyUsers = mutableListOf( + UserEntity(1L, "user1_id", "user1", "server1", "1"), + UserEntity(2L, "user2_id", "user2", "server1", "2"), + UserEntity(0L, "user3_id", "user3", "server2", "3") + ) + private var activeUserId: Long? = 1L + + override fun getActiveUser(): Maybe { + return Maybe.fromCallable { dummyUsers.find { it.id == activeUserId && !it.scheduledForDeletion } } + } + + override fun getActiveUserObservable(): Observable { + return Observable.fromCallable { dummyUsers.find { it.id == activeUserId && !it.scheduledForDeletion } } + } + + override fun getActiveUserSynchronously(): UserEntity? { + return dummyUsers.find { it.id == activeUserId && !it.scheduledForDeletion } + } + + override fun deleteUser(user: UserEntity): Int { + val initialSize = dummyUsers.size + dummyUsers.removeIf { it.id == user.id } + return initialSize - dummyUsers.size + } + + override fun updateUser(user: UserEntity): Int { + val index = dummyUsers.indexOfFirst { it.id == user.id } + return if (index != -1) { + dummyUsers[index] = user + 1 + } else { + 0 + } + } + + override fun saveUser(user: UserEntity): Long { + val newUser = user.copy(id = dummyUsers.size + 1L) + dummyUsers.add(newUser) + return newUser.id + } + + override fun saveUsers(vararg users: UserEntity): List { + return users.map { saveUser(it) } + } + + override fun getUsers(): Single> { + return Single.just(dummyUsers.filter { !it.scheduledForDeletion }) + } + + override fun getUserWithId(id: Long): Maybe { + return Maybe.fromCallable { dummyUsers.find { it.id == id } } + } + + override fun getUserWithIdNotScheduledForDeletion(id: Long): Maybe { + return Maybe.fromCallable { dummyUsers.find { it.id == id && !it.scheduledForDeletion } } + } + + override fun getUserWithUserId(userId: String): Maybe { + return Maybe.fromCallable { dummyUsers.find { it.userId == userId } } + } + + override fun getUsersScheduledForDeletion(): Single> { + return Single.just(dummyUsers.filter { it.scheduledForDeletion }) + } + + override fun getUsersNotScheduledForDeletion(): Single> { + return Single.just(dummyUsers.filter { !it.scheduledForDeletion }) + } + + override fun getUserWithUsernameAndServer(username: String, server: String): Maybe { + return Maybe.fromCallable { dummyUsers.find { it.username == username } } + } + + override fun setUserAsActiveWithId(id: Long): Int { + activeUserId = id + return 1 + } + + override fun updatePushState(id: Long, state: PushConfigurationState): Single { + val index = dummyUsers.indexOfFirst { it.id == id } + return if (index != -1) { + dummyUsers[index] = dummyUsers[index] + Single.just(1) + } else { + Single.just(0) + } + } +} + +class DummyConversationDaoImpl : ConversationsDao { + override fun getConversationsForUser(accountId: Long): Flow> = flowOf() + + override fun getConversationForUser(accountId: Long, token: String): Flow = flowOf() + + override fun upsertConversations(conversationEntities: List) { /* */ } + + override fun deleteConversations(conversationIds: List) { /* */ } + + override fun updateConversation(conversationEntity: ConversationEntity) { /* */ } + + override fun clearAllConversationsForUser(accountId: Long) { /* */ } +} + +class DummyChatBlocksDaoImpl : ChatBlocksDao { + override fun deleteChatBlocks(blocks: List) { /* */ } + + override fun getChatBlocks(internalConversationId: String): Flow> = flowOf() + + override fun getChatBlocksContainingMessageId( + internalConversationId: String, + messageId: Long + ): Flow> = flowOf() + + override fun getConnectedChatBlocks( + internalConversationId: String, + oldestMessageId: Long, + newestMessageId: Long + ): Flow> = flowOf() + + override suspend fun upsertChatBlock(chatBlock: ChatBlockEntity) { /* */ } + + override fun clearChatBlocksForUser(pattern: String) { /* */ } + + override fun deleteChatBlocksOlderThan(internalConversationId: String, messageId: Long) { /* */ } +}