diff --git a/WordPress/build.gradle b/WordPress/build.gradle index 2a0fc5eb09bd..904035c3af15 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -374,6 +374,7 @@ static def addBuildConfigFieldsFromPrefixedProperties(variant, properties, prefi dependencies { implementation(libs.androidx.navigation.compose) + implementation(libs.commonmark) compileOnly project(path: ':libs:annotations') ksp project(':libs:processors') implementation (project(path:':libs:networking')) { diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt index 32563ff5d443..df3d552935f6 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt @@ -1,10 +1,14 @@ package org.wordpress.android.support.aibot.model +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString import java.util.Date +@Immutable data class BotMessage( val id: Long, - val text: String, + val rawText: String, + val formattedText: AnnotatedString, val date: Date, val isWrittenByUser: Boolean ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt index 8ea61acee0c7..86b37527a7ac 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt @@ -7,6 +7,7 @@ import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.networking.restapi.WpComApiClientProvider import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage +import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString import org.wordpress.android.util.AppLog import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult @@ -14,10 +15,12 @@ import uniffi.wp_api.AddMessageToBotConversationParams import uniffi.wp_api.BotConversationSummary import uniffi.wp_api.CreateBotConversationParams import uniffi.wp_api.GetBotConversationParams +import java.util.Date import javax.inject.Inject import javax.inject.Named private const val BOT_ID = "jetpack-chat-mobile" +private const val ITEMS_PER_PAGE = 20 class AIBotSupportRepository @Inject constructor( private val appLogWrapper: AppLogWrapper, @@ -66,12 +69,15 @@ class AIBotSupportRepository @Inject constructor( } } - suspend fun loadConversation(chatId: Long): BotConversation? = withContext(ioDispatcher) { + suspend fun loadConversation(chatId: Long, pageNumber: Long = 1L): BotConversation? = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportBots().getBotConversation( botId = BOT_ID, chatId = chatId.toULong(), - params = GetBotConversationParams() + params = GetBotConversationParams( + pageNumber = pageNumber.toULong(), + itemsPerPage = ITEMS_PER_PAGE.toULong() + ) ) } when (response) { @@ -157,15 +163,16 @@ class AIBotSupportRepository @Inject constructor( BotConversation ( id = chatId.toLong(), createdAt = createdAt, - mostRecentMessageDate = messages.last().createdAt, - lastMessage = messages.last().content, + mostRecentMessageDate = messages.lastOrNull()?.createdAt ?: Date(), + lastMessage = messages.lastOrNull()?.content.orEmpty(), messages = messages.map { it.toBotMessage() } ) private fun uniffi.wp_api.BotMessage.toBotMessage(): BotMessage = BotMessage( id = messageId.toLong(), - text = content, + rawText = content, + formattedText = markdownToAnnotatedString(content), date = createdAt, isWrittenByUser = role == "user" ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt index c6ee7c9f4848..bbf1f2320615 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.aibot.ui +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send @@ -28,6 +30,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -35,29 +39,30 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import kotlinx.coroutines.launch +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.KeyboardOptions -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.text.style.TextAlign import org.wordpress.android.R -import org.wordpress.android.support.aibot.util.formatRelativeTime -import org.wordpress.android.support.aibot.util.generateSampleBotConversations import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage +import org.wordpress.android.support.aibot.util.formatRelativeTime +import org.wordpress.android.support.aibot.util.generateSampleBotConversations import org.wordpress.android.ui.compose.theme.AppThemeM3 +private const val PAGINATION_TRIGGER_THRESHOLD = 4 + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AIBotConversationDetailScreen( @@ -65,26 +70,40 @@ fun AIBotConversationDetailScreen( conversation: BotConversation, isLoading: Boolean, isBotTyping: Boolean, + isLoadingOlderMessages: Boolean, + hasMorePages: Boolean, canSendMessage: Boolean, userName: String, onBackClick: () -> Unit, - onSendMessage: (String) -> Unit + onSendMessage: (String) -> Unit, + onLoadOlderMessages: () -> Unit ) { var messageText by remember { mutableStateOf("") } val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() - - // Scroll to bottom when conversation changes or messages are added or typing state changes - LaunchedEffect(conversation.id, conversation.messages.size, isBotTyping) { - if (conversation.messages.isNotEmpty() || isBotTyping) { - coroutineScope.launch { - // +2 for welcome header and spacer, +1 if typing indicator is showing - val itemCount = conversation.messages.size + 2 + if (isBotTyping) 1 else 0 - listState.animateScrollToItem(itemCount) - } + + // Scroll to bottom when new messages are added at the end (not when loading older messages at the beginning) + // Only scroll to bottom when: + // 1. The last message changes (new message added at the end) + // 2. Bot starts typing + // 3. We're not loading older messages (which adds messages at the beginning) + LaunchedEffect(conversation.id, conversation.messages.lastOrNull()?.id, isBotTyping) { + if ((conversation.messages.isNotEmpty() || isBotTyping) && !isLoadingOlderMessages) { + listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) } } + // Detect when user scrolls near the top to load older messages + LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) { + snapshotFlow { listState.firstVisibleItemIndex } + .collect { firstVisibleIndex -> + val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= PAGINATION_TRIGGER_THRESHOLD + + if (shouldLoadMore && !isLoading && hasMorePages) { + onLoadOlderMessages() + } + } + } + val resources = LocalResources.current Scaffold( @@ -128,8 +147,25 @@ fun AIBotConversationDetailScreen( state = listState, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - WelcomeHeader(userName) + // Show loading indicator at top when loading older messages + if (isLoadingOlderMessages) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + + // Only show welcome header when we're at the beginning (no more pages to load) + if (!hasMorePages) { + item { + WelcomeHeader(userName) + } } // Key ensures the items recompose when messages change @@ -163,10 +199,17 @@ fun AIBotConversationDetailScreen( @Composable private fun WelcomeHeader(userName: String) { + val greeting = stringResource(R.string.ai_bot_welcome_greeting, userName) + val message = stringResource(R.string.ai_bot_welcome_message) + val welcomeDescription = "$greeting. $message" + Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .clearAndSetSemantics { + contentDescription = welcomeDescription + }, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), @@ -188,7 +231,8 @@ private fun WelcomeHeader(userName: String) { text = stringResource(R.string.ai_bot_welcome_greeting, userName), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() } ) Text( @@ -209,6 +253,7 @@ private fun ChatInputBar( onSendClick: () -> Unit ) { val canSend = messageText.isNotBlank() && canSendMessage + val messageInputLabel = stringResource(R.string.ai_bot_message_input_placeholder) Row( modifier = Modifier @@ -221,8 +266,10 @@ private fun ChatInputBar( OutlinedTextField( value = messageText, onValueChange = onMessageTextChange, - modifier = Modifier.weight(1f), - placeholder = { Text(stringResource(R.string.ai_bot_message_input_placeholder)) }, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = messageInputLabel }, + placeholder = { Text(messageInputLabel) }, maxLines = 4, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) @@ -246,6 +293,10 @@ private fun ChatInputBar( @Composable private fun MessageBubble(message: BotMessage, resources: android.content.res.Resources) { + val timestamp = formatRelativeTime(message.date, resources) + val author = stringResource(if (message.isWrittenByUser) R.string.ai_bot_you else R.string.ai_bot_support_bot) + val messageDescription = "$author, $timestamp. ${message.formattedText}" + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (message.isWrittenByUser) { @@ -271,22 +322,26 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re ) ) .padding(12.dp) + .clearAndSetSemantics { + contentDescription = messageDescription + } ) { Column { Text( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = if (message.isWrittenByUser) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + text = message.formattedText, + style = MaterialTheme.typography.bodyMedium.copy( + color = if (message.isWrittenByUser) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = formatRelativeTime(message.date, resources), + text = timestamp, style = MaterialTheme.typography.bodySmall, color = if (message.isWrittenByUser) { MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) @@ -317,6 +372,7 @@ private fun TypingIndicatorBubble() { ) ) .padding(16.dp) + .semantics { contentDescription = "AI Bot is typing" } ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -368,9 +424,12 @@ private fun ConversationDetailScreenPreview() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -388,9 +447,12 @@ private fun ConversationDetailScreenPreviewDark() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -408,9 +470,12 @@ private fun ConversationDetailScreenWordPressPreview() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -428,9 +493,12 @@ private fun ConversationDetailScreenPreviewWordPressDark() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt index e5664db6b7a2..2289283ccb93 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt @@ -3,201 +3,117 @@ package org.wordpress.android.support.aibot.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.content.res.Resources import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources +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.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import org.wordpress.android.R import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.aibot.util.generateSampleBotConversations -import org.wordpress.android.support.common.ui.EmptyConversationsView +import org.wordpress.android.support.common.ui.ConversationsListScreen +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun AIBotConversationsListScreen( snackbarHostState: SnackbarHostState, - conversations: StateFlow>, - isLoading: Boolean, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, onConversationClick: (BotConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, onRefresh: () -> Unit, ) { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.ai_bot_conversations_title)) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(R.string.ai_bot_back_button_content_description) - ) - } - }, - actions = { - IconButton(onClick = { onCreateNewConversationClick() }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.ai_bot_new_conversation_content_description) - ) - } - } - ) - }, - ) { contentPadding -> - val conversationsList by conversations.collectAsState() - - PullToRefreshBox( - isRefreshing = isLoading, - onRefresh = onRefresh, - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - ) { - when { - conversationsList.isEmpty() && !isLoading -> { - EmptyConversationsView( - modifier = Modifier.fillMaxSize(), - onCreateNewConversationClick = onCreateNewConversationClick - ) - } - else -> { - ShowConversationsList( - modifier = Modifier.fillMaxSize(), - conversations = conversations, - onConversationClick = onConversationClick - ) - } - } - } - } -} - -@Composable -private fun ShowConversationsList( - modifier: Modifier, - conversations: StateFlow>, - onConversationClick: (BotConversation) -> Unit -) { - val conversations by conversations.collectAsState() val resources = LocalResources.current - - LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item { - // Add top spacing - Spacer(modifier = Modifier.padding(top = 4.dp)) - } - - items(conversations) { conversation -> - ConversationCard( + ConversationsListScreen( + title = stringResource(R.string.ai_bot_conversations_title), + addConversationContentDescription = stringResource(R.string.ai_bot_new_conversation_content_description), + snackbarHostState = snackbarHostState, + conversations = conversations, + conversationsState = conversationsState, + onBackClick = onBackClick, + onCreateNewConversationClick = onCreateNewConversationClick, + onRefresh = onRefresh, + conversationListItem = { conversation -> + BotConversationListItem( conversation = conversation, resources = resources, onClick = { onConversationClick(conversation) } ) } - - item { - // Add bottom spacing - Spacer(modifier = Modifier.padding(bottom = 4.dp)) - } - } + ) } @Composable -private fun ConversationCard( +private fun BotConversationListItem( conversation: BotConversation, resources: Resources, onClick: () -> Unit ) { - Card( + Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + Column( + modifier = Modifier.weight(1f) ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = conversation.lastMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + Text( + text = conversation.lastMessage, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = formatRelativeTime(conversation.mostRecentMessageDate, resources), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + modifier = Modifier.padding(top = 4.dp), + text = formatRelativeTime(conversation.mostRecentMessageDate, resources), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + + Icon( + painter = painterResource(R.drawable.ic_chevron_right_white_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) } } @Preview(showBackground = true, name = "Conversations List") @Composable private fun ConversationsScreenPreview() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoading = false, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -209,14 +125,13 @@ private fun ConversationsScreenPreview() { @Preview(showBackground = true, name = "Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewDark() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoading = false, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -228,14 +143,13 @@ private fun ConversationsScreenPreviewDark() { @Preview(showBackground = true, name = "Conversations List") @Composable private fun ConversationsScreenWordPressPreview() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoading = true, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -247,14 +161,13 @@ private fun ConversationsScreenWordPressPreview() { @Preview(showBackground = true, name = "Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewWordPressDark() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoading = true, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -266,14 +179,13 @@ private fun ConversationsScreenPreviewWordPressDark() { @Preview(showBackground = true, name = "Empty Conversations List") @Composable private fun EmptyConversationsScreenPreview() { - val emptyConversations = MutableStateFlow(emptyList()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = emptyConversations.asStateFlow(), - isLoading = false, + conversations = emptyList(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -285,14 +197,13 @@ private fun EmptyConversationsScreenPreview() { @Preview(showBackground = true, name = "Empty Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun EmptyConversationsScreenPreviewDark() { - val emptyConversations = MutableStateFlow(emptyList()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = emptyConversations.asStateFlow(), - isLoading = false, + conversations = emptyList(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt index 79f2bde12052..10adaf9963fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt @@ -109,11 +109,12 @@ class AIBotSupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name, ) { composable(route = ConversationScreen.List.name) { - val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + val conversationsState by viewModel.conversationsState.collectAsState() + val conversations by viewModel.conversations.collectAsState() AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = viewModel.conversations, - isLoading = isLoadingConversations, + conversations = conversations, + conversationsState = conversationsState, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, @@ -131,6 +132,8 @@ class AIBotSupportActivity : AppCompatActivity() { val selectedConversation by viewModel.selectedConversation.collectAsState() val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isBotTyping by viewModel.isBotTyping.collectAsState() + val isLoadingOlderMessages by viewModel.isLoadingOlderMessages.collectAsState() + val hasMorePages by viewModel.hasMorePages.collectAsState() val canSendMessage by viewModel.canSendMessage.collectAsState() val userInfo by viewModel.userInfo.collectAsState() selectedConversation?.let { conversation -> @@ -140,10 +143,15 @@ class AIBotSupportActivity : AppCompatActivity() { conversation = conversation, isLoading = isLoadingConversation, isBotTyping = isBotTyping, + isLoadingOlderMessages = isLoadingOlderMessages, + hasMorePages = hasMorePages, canSendMessage = canSendMessage, onBackClick = { viewModel.onBackClick() }, onSendMessage = { text -> viewModel.sendMessage(text) + }, + onLoadOlderMessages = { + viewModel.loadOlderMessages() } ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt index 4d0423d4f66b..b069f10a44e8 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt @@ -6,13 +6,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage import org.wordpress.android.support.aibot.repository.AIBotSupportRepository import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date import javax.inject.Inject @@ -21,13 +25,23 @@ class AIBotSupportViewModel @Inject constructor( accountStore: AccountStore, private val aiBotSupportRepository: AIBotSupportRepository, appLogWrapper: AppLogWrapper, -) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + networkUtilsWrapper: NetworkUtilsWrapper, +) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { private val _canSendMessage = MutableStateFlow(true) val canSendMessage: StateFlow = _canSendMessage.asStateFlow() private val _isBotTyping = MutableStateFlow(false) val isBotTyping: StateFlow = _isBotTyping.asStateFlow() + private val _isLoadingOlderMessages = MutableStateFlow(false) + val isLoadingOlderMessages: StateFlow = _isLoadingOlderMessages.asStateFlow() + + private val _hasMorePages = MutableStateFlow(true) + val hasMorePages: StateFlow = _hasMorePages.asStateFlow() + + private val paginationMutex = Mutex() + private var currentPage = 1L + override fun initRepository(accessToken: String) { aiBotSupportRepository.init(accessToken, accountStore.account.userId) } @@ -36,7 +50,9 @@ class AIBotSupportViewModel @Inject constructor( override suspend fun getConversation(conversationId: Long): BotConversation? { _canSendMessage.value = false - return aiBotSupportRepository.loadConversation(conversationId).also { + currentPage = 1L + _hasMorePages.value = true + return aiBotSupportRepository.loadConversation(conversationId, pageNumber = currentPage).also { conversation -> _canSendMessage.value = true } } @@ -52,10 +68,68 @@ class AIBotSupportViewModel @Inject constructor( messages = listOf() ) _canSendMessage.value = true + currentPage = 1L + _hasMorePages.value = false setNewConversation(botConversation) } } + @Suppress("TooGenericExceptionCaught") + fun loadOlderMessages() { + if (!_hasMorePages.value || _isLoadingOlderMessages.value) { + return + } + + viewModelScope.launch { + // Use mutex to prevent concurrent pagination requests + paginationMutex.withLock { + // Double-check conditions after acquiring lock + if (!_hasMorePages.value || _isLoadingOlderMessages.value) { + return@launch + } + + try { + _isLoadingOlderMessages.value = true + val conversationId = _selectedConversation.value?.id ?: return@withLock + + currentPage++ + val olderMessagesConversation = aiBotSupportRepository.loadConversation( + conversationId, + pageNumber = currentPage + ) + + if (olderMessagesConversation != null) { + val olderMessages = olderMessagesConversation.messages + + // Check if we've reached the end (empty messages) + if (olderMessages.isEmpty()) { + _hasMorePages.value = false + } else { + // Prepend older messages to the existing ones + // (older messages go at the beginning of the list) + val currentMessages = _selectedConversation.value?.messages ?: emptyList() + _selectedConversation.value = _selectedConversation.value?.copy( + messages = olderMessages + currentMessages + ) + } + } else { + // Error loading, stay on current page + currentPage-- + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") + } + } catch (throwable: Throwable) { + currentPage-- + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } finally { + _isLoadingOlderMessages.value = false + } + } + } + } + @Suppress("TooGenericExceptionCaught") fun sendMessage(message: String) { viewModelScope.launch { @@ -65,13 +139,14 @@ class AIBotSupportViewModel @Inject constructor( _canSendMessage.value = false val now = Date() - val userMessage = BotMessage( + val botMessage = BotMessage( id = System.currentTimeMillis(), - text = message, + rawText = message, + formattedText = markdownToAnnotatedString(message), date = now, isWrittenByUser = true ) - val currentMessages = (_selectedConversation.value?.messages ?: emptyList()) + userMessage + val currentMessages = (_selectedConversation.value?.messages ?: emptyList()) + botMessage _selectedConversation.value = _selectedConversation.value?.copy( messages = currentMessages ) @@ -83,7 +158,7 @@ class AIBotSupportViewModel @Inject constructor( if (conversation != null) { val finalConversation = conversation.copy( - lastMessage = conversation.messages.last().text, + lastMessage = conversation.messages.last().rawText, messages = (_selectedConversation.value?.messages ?: emptyList()) + conversation.messages ) // Update the conversations list diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt index 7c595b383e96..e007d51a4795 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt @@ -1,6 +1,7 @@ package org.wordpress.android.support.aibot.util import android.content.res.Resources +import androidx.compose.ui.text.AnnotatedString import org.wordpress.android.R import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage @@ -63,66 +64,53 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 1001, - text = "Hi, I'm having trouble with the app. It keeps crashing when I try to open it after " + - "the latest update. Can you help?", + rawText = "", + formattedText = AnnotatedString("Hi, I'm having trouble with the app. It keeps crashing " + + "when I try to open it after " + + "the latest update. Can you help?"), date = Date(now.time - 3_600_000), // 1 hour ago isWrittenByUser = true ), BotMessage( id = 1002, - text = "I'm sorry to hear you're experiencing crashes! I'd be happy to help you troubleshoot " + + rawText = "", + formattedText = AnnotatedString("I'm sorry to hear you're experiencing crashes! I'd be " + + "happy to help you troubleshoot " + "this issue. Let me ask a few questions to better understand what's happening. " + - "What device are you using and what Android version are you running?", + "What device are you using and what Android version are you running?"), date = Date(now.time - 3_540_000), // 59 minutes ago isWrittenByUser = false ), BotMessage( id = 1003, - text = "I'm using a Pixel 8 Pro with Android 14. The app worked fine before the update yesterday.", + rawText = "", + formattedText = AnnotatedString("I'm using a Pixel 8 Pro with Android 14. The app worked " + + "fine before the update yesterday."), date = Date(now.time - 3_480_000), // 58 minutes ago isWrittenByUser = true ), BotMessage( id = 1004, - text = "Thank you for that information! Android 14 on Pixel 8 Pro should work well with our " + + rawText = "", + formattedText = AnnotatedString("Thank you for that information! Android 14 on Pixel 8 Pro " + + "should work well with our " + "latest update. Let's try a few troubleshooting steps:\n\n1. First, try force-closing " + "the app and reopening it\n2. If that doesn't work, try restarting your phone\n" + "3. As a last resort, you might need to clear app data or reinstall\n\nCan you try " + - "step 1 first and let me know if that helps?", + "step 1 first and let me know if that helps?"), date = Date(now.time - 3_420_000), // 57 minutes ago isWrittenByUser = false ), BotMessage( id = 1005, - text = "I tried force-closing and restarting my phone, but it's still crashing immediately when " + + rawText = "" + "I tap the app icon. Should I try reinstalling?", + formattedText = AnnotatedString("I tried force-closing and restarting my phone, but it's " + + "still crashing immediately when " + + "I tap the app icon. Should I try reinstalling?"), date = Date(now.time - 3_300_000), // 55 minutes ago isWrittenByUser = true ), - BotMessage( - id = 1006, - text = "Yes, let's try reinstalling the app. This will often resolve issues caused by corrupted " + - "app data during updates. Here's what to do:\n\n1. Long press the app icon and tap " + - "'Uninstall'\n2. Go to the Play Store and reinstall the app\n" + - "3. Sign back into your account\n\nYour data should be preserved if you're signed " + - "into your account. Give this a try and let me know how it goes!", - date = Date(now.time - 3_240_000), // 54 minutes ago - isWrittenByUser = false - ), - BotMessage( - id = 1007, - text = "That worked! The app is opening normally now. Thank you so much for your help!", - date = Date(now.time - 180_000), // 3 minutes ago - isWrittenByUser = true - ), - BotMessage( - id = 1008, - text = "Wonderful! I'm so glad that resolved the issue for you. The reinstall process often " + - "fixes problems that occur during app updates. If you run into any other issues, please " + - "don't hesitate to reach out. Is there anything else I can help you with today?", - date = Date(now.time - 120_000), // 2 minutes ago - isWrittenByUser = false - ) ) ), @@ -135,16 +123,20 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 2001, - text = "I just created my WordPress site and need help getting started. Where should I begin?", + rawText = "", + formattedText = AnnotatedString("I just created my WordPress site and need help getting " + + "started. Where should I begin?"), date = Date(now.time - 7_800_000), isWrittenByUser = true ), BotMessage( id = 2002, - text = "Congratulations on your new site! I'd be happy to help you get started. Here are the key " + + rawText = "", + formattedText = AnnotatedString("Congratulations on your new site! I'd be happy to help " + + "you get started. Here are the key " + "first steps:\n\n1. Choose and customize a theme\n2. Create your first pages (Home, " + "About, Contact)\n3. Set up your site navigation\n4. Add your first blog post\n\n" + - "Which of these would you like to tackle first?", + "Which of these would you like to tackle first?"), date = Date(now.time - 7_200_000), isWrittenByUser = false ) @@ -160,70 +152,23 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 3001, - text = "How can I change the colors on my site? I want to match my brand.", + rawText = "", + formattedText = AnnotatedString("How can I change the colors on my site? I want to " + + "match my brand."), date = Date(now.time - 87_000_000), isWrittenByUser = true ), BotMessage( id = 3002, - text = "You can change the colors by going to Appearance → Customize → Colors in your dashboard. " + + rawText = "", + formattedText = AnnotatedString("You can change the colors by going to Appearance → " + + "Customize → Colors in your dashboard. " + "Most themes allow you to customize colors for backgrounds, text, links, and buttons. " + - "Would you like step-by-step instructions?", + "Would you like step-by-step instructions?"), date = Date(now.time - 86_400_000), isWrittenByUser = false ) ) ), - - // Conversation 4: SEO Help - BotConversation( - id = 1237, - createdAt = Date(now.time - 259_800_000), - mostRecentMessageDate = Date(now.time - 259_200_000), // 3 days ago - lastMessage = "To improve your SEO, consider installing an SEO plugin like Yoast.", - messages = listOf( - BotMessage( - id = 4001, - text = "My site isn't showing up in Google search results. What should I do?", - date = Date(now.time - 259_800_000), - isWrittenByUser = true - ), - BotMessage( - id = 4002, - text = "To improve your SEO, consider these steps:\n\n1. Install an SEO plugin like Yoast\n" + - "2. Submit your sitemap to Google Search Console\n" + - "3. Use descriptive titles and meta descriptions\n4. Create quality content regularly\n" + - "5. Build internal links between pages\n\n" + - "Would you like detailed guidance on any of these?", - date = Date(now.time - 259_200_000), - isWrittenByUser = false - ) - ) - ), - - // Conversation 5: Performance Questions - BotConversation( - id = 1238, - createdAt = Date(now.time - 605_400_000), - mostRecentMessageDate = Date(now.time - 604_800_000), // 1 week ago - lastMessage = "Your site is loading well, but here are some tips to optimize further.", - messages = listOf( - BotMessage( - id = 5001, - text = "My website seems to be loading slowly. What can I do to speed it up?", - date = Date(now.time - 605_400_000), - isWrittenByUser = true - ), - BotMessage( - id = 5002, - text = "Your site is loading well, but here are some tips to optimize further:\n\n" + - "1. Optimize images (compress before uploading)\n2. Use a caching plugin\n" + - "3. Enable lazy loading for images\n4. Minimize plugins\n" + - "5. Use a CDN for static assets\n\nLet me know which area you'd like to focus on first!", - date = Date(now.time - 604_800_000), - isWrittenByUser = false - ) - ) - ) ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt new file mode 100644 index 000000000000..801221632345 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt @@ -0,0 +1,105 @@ +package org.wordpress.android.support.common.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.wordpress.android.support.common.model.Conversation +import org.wordpress.android.ui.compose.components.MainTopAppBar +import org.wordpress.android.ui.compose.components.NavigationIcons + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationsListScreen( + modifier: Modifier = Modifier, + title: String, + addConversationContentDescription: String, + snackbarHostState: SnackbarHostState, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, + onBackClick: () -> Unit, + onCreateNewConversationClick: () -> Unit, + onRefresh: () -> Unit, + conversationListItem: @Composable (T) -> Unit +) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + MainTopAppBar( + title = title, + navigationIcon = NavigationIcons.BackIcon, + onNavigationIconClick = onBackClick, + actions = { + IconButton(onClick = { onCreateNewConversationClick() }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = addConversationContentDescription + ) + } + } + ) + } + ) { contentPadding -> + PullToRefreshBox( + isRefreshing = conversationsState is ConversationsSupportViewModel.ConversationsState.Loading, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize() + ) { + ConversationsList( + modifier = Modifier.padding(contentPadding), + conversations = conversations, + conversationsState = conversationsState, + onCreateNewConversationClick = onCreateNewConversationClick, + conversationListItem = conversationListItem, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationsList( + modifier: Modifier, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, + onCreateNewConversationClick: () -> Unit, + conversationListItem: @Composable (T) -> Unit +) { + if (conversations.isEmpty() && conversationsState is ConversationsSupportViewModel.ConversationsState.Loaded) { + EmptyConversationsView( + modifier = modifier, + onCreateNewConversationClick = onCreateNewConversationClick + ) + } else if (conversationsState is ConversationsSupportViewModel.ConversationsState.NoNetwork) { + OfflineConversationsView() + } else if (conversationsState is ConversationsSupportViewModel.ConversationsState.Error) { + ErrorConversationsView() + } else { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { + items( + items = conversations, + key = { it.getConversationId() } + ) { conversation -> + conversationListItem(conversation) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt index b9bf9ef6d692..7b99c87a740f 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt @@ -14,10 +14,12 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.model.Conversation import org.wordpress.android.support.common.model.UserInfo import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, + private val networkUtilsWrapper: NetworkUtilsWrapper, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToConversationDetail : NavigationEvent() @@ -25,6 +27,13 @@ abstract class ConversationsSupportViewModel( data object NavigateBack : NavigationEvent() } + sealed class ConversationsState { + data object Loading : ConversationsState() + data object Loaded : ConversationsState() + data object NoNetwork : ConversationsState() + data object Error : ConversationsState() + } + private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() @@ -44,8 +53,8 @@ abstract class ConversationsSupportViewModel( val userInfo: StateFlow = _userInfo.asStateFlow() @Suppress("VariableNaming") - protected val _isLoadingConversations = MutableStateFlow(false) - val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() + protected val _conversationsState = MutableStateFlow(ConversationsState.Loading) + val conversationsState: StateFlow = _conversationsState.asStateFlow() @Suppress("VariableNaming") protected val _errorMessage = MutableStateFlow(null) @@ -88,17 +97,23 @@ abstract class ConversationsSupportViewModel( @Suppress("TooGenericExceptionCaught") private suspend fun loadConversations() { try { - _isLoadingConversations.value = true + if (!networkUtilsWrapper.isNetworkAvailable()) { + _conversationsState.value = ConversationsState.NoNetwork + return + } + + _conversationsState.value = ConversationsState.Loading val conversations = getConversations() _conversations.value = conversations + _conversationsState.value = ConversationsState.Loaded } catch (throwable: Throwable) { _errorMessage.value = ErrorType.GENERAL + _conversationsState.value = ConversationsState.Error appLogWrapper.e( AppLog.T.SUPPORT, "Error loading support conversations: " + "${throwable.message} - ${throwable.stackTraceToString()}" ) } - _isLoadingConversations.value = false } protected abstract suspend fun getConversations(): List diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/EmptyConversationsView.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/EmptyConversationsView.kt index 1f120063ed77..d74abf745079 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/EmptyConversationsView.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/EmptyConversationsView.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.common.ui +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -15,8 +16,10 @@ import androidx.compose.ui.Modifier 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 org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 @Composable fun EmptyConversationsView( @@ -60,3 +63,47 @@ fun EmptyConversationsView( } } } + +@Preview(showBackground = true, name = "Empty Conversations View") +@Composable +private fun EmptyConversationsViewPreview() { + AppThemeM3(isDarkTheme = false) { + EmptyConversationsView( + modifier = Modifier, + onCreateNewConversationClick = { } + ) + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun EmptyConversationsViewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + EmptyConversationsView( + modifier = Modifier, + onCreateNewConversationClick = { } + ) + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - WordPress") +@Composable +private fun EmptyConversationsViewPreviewWordPress() { + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + EmptyConversationsView( + modifier = Modifier, + onCreateNewConversationClick = { } + ) + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun EmptyConversationsViewPreviewWordPressDark() { + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + EmptyConversationsView( + modifier = Modifier, + onCreateNewConversationClick = { } + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ErrorConversationsView.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ErrorConversationsView.kt new file mode 100644 index 000000000000..6d1232865d93 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ErrorConversationsView.kt @@ -0,0 +1,61 @@ +package org.wordpress.android.support.common.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.EmptyContentM3 +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@Composable +fun ErrorConversationsView() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmptyContentM3( + title = stringResource(R.string.error_generic), + image = R.drawable.img_jetpack_empty_state, + imageContentDescription = stringResource(R.string.error_generic) + ) + } +} + +@Preview(showBackground = true, name = "Error Conversations View") +@Composable +private fun ErrorConversationsViewPreview() { + AppThemeM3(isDarkTheme = false) { + ErrorConversationsView() + } +} + +@Preview(showBackground = true, name = "Error Conversations View - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ErrorConversationsViewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + ErrorConversationsView() + } +} + +@Preview(showBackground = true, name = "Error Conversations View - WordPress") +@Composable +private fun ErrorConversationsViewPreviewWordPress() { + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + ErrorConversationsView() + } +} + +@Preview(showBackground = true, name = "Error Conversations View - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ErrorConversationsViewPreviewWordPressDark() { + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + ErrorConversationsView() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/OfflineConversationsView.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/OfflineConversationsView.kt new file mode 100644 index 000000000000..aee979332f5d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/OfflineConversationsView.kt @@ -0,0 +1,61 @@ +package org.wordpress.android.support.common.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.EmptyContentM3 +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@Composable +fun OfflineConversationsView() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmptyContentM3( + title = stringResource(R.string.no_network_title), + image = R.drawable.img_illustration_cloud_off_152dp, + imageContentDescription = stringResource(R.string.no_network_title) + ) + } +} + +@Preview(showBackground = true, name = "Empty Conversations View") +@Composable +private fun OfflineConversationsViewPreview() { + AppThemeM3(isDarkTheme = false) { + OfflineConversationsView() + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun OfflineConversationsViewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + OfflineConversationsView() + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - WordPress") +@Composable +private fun OfflineConversationsViewPreviewWordPress() { + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + OfflineConversationsView() + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun OfflineConversationsViewPreviewWordPressDark() { + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + OfflineConversationsView() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportMessage.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportMessage.kt index 22f63a846a48..ae76fdb21a42 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportMessage.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportMessage.kt @@ -1,10 +1,14 @@ package org.wordpress.android.support.he.model +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString import java.util.Date +@Immutable data class SupportMessage( val id: Long, - val text: String, + val rawText: String, + val formattedText: AnnotatedString, val createdAt: Date, val authorName: String, val authorIsUser: Boolean diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt b/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt index 2a5e3530d4f6..be88432043f9 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt @@ -7,6 +7,7 @@ import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.networking.restapi.WpComApiClientProvider import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage +import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString import org.wordpress.android.util.AppLog import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult @@ -194,7 +195,8 @@ class HESupportRepository @Inject constructor( private fun uniffi.wp_api.SupportMessage.toSupportMessage(): SupportMessage = SupportMessage( id = this.id.toLong(), - text = this.content, + rawText = this.content, + formattedText = markdownToAnnotatedString(this.content), createdAt = this.createdAt, authorName = when (this.author) { is SupportMessageAuthor.User -> (this.author as SupportMessageAuthor.User).v1.displayName diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt index 6fbd41c4f3fb..eef2adb5c326 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt @@ -40,18 +40,23 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.support.he.util.generateSampleHESupportConversations import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons @@ -133,10 +138,8 @@ fun HEConversationDetailScreen( key = { it.id } ) { message -> MessageItem( - authorName = message.authorName, - messageText = message.text, - timestamp = formatRelativeTime(message.createdAt, resources), - isUserMessage = message.authorIsUser + message = message, + timestamp = formatRelativeTime(message.createdAt, resources) ) } @@ -189,10 +192,20 @@ private fun ConversationHeader( lastUpdated: String, isLoading: Boolean = false ) { + val headerDescription = if (!isLoading) { + "${stringResource(R.string.he_support_message_count, messageCount)}. " + + stringResource(R.string.he_support_last_updated, lastUpdated) + } else { + stringResource(R.string.he_support_last_updated, lastUpdated) + } + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .clearAndSetSemantics { + contentDescription = headerDescription + }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -236,23 +249,24 @@ private fun ConversationTitleCard(title: String) { text = title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() } ) } } @Composable private fun MessageItem( - authorName: String, - messageText: String, - timestamp: String, - isUserMessage: Boolean + message: SupportMessage, + timestamp: String ) { + val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" + Box( modifier = Modifier .fillMaxWidth() .background( - color = if (isUserMessage) { + color = if (message.authorIsUser) { MaterialTheme.colorScheme.primary.copy(alpha = 0.20f) } else { MaterialTheme.colorScheme.surfaceVariant @@ -260,6 +274,9 @@ private fun MessageItem( shape = RoundedCornerShape(8.dp) ) .padding(16.dp) + .clearAndSetSemantics { + contentDescription = messageDescription + } ) { Column( modifier = Modifier.fillMaxWidth() @@ -270,10 +287,10 @@ private fun MessageItem( verticalAlignment = Alignment.CenterVertically ) { Text( - text = authorName, + text = message.authorName, style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isUserMessage) FontWeight.Bold else FontWeight.Normal, - color = if (isUserMessage) { + fontWeight = if (message.authorIsUser) FontWeight.Bold else FontWeight.Normal, + color = if (message.authorIsUser) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -290,9 +307,10 @@ private fun MessageItem( Spacer(modifier = Modifier.height(8.dp)) Text( - text = messageText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + text = message.formattedText, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ) ) } } @@ -303,6 +321,8 @@ private fun ReplyButton( enabled: Boolean = true, onClick: () -> Unit ) { + val replyButtonLabel = stringResource(R.string.he_support_reply_button) + Box( modifier = Modifier .fillMaxWidth() @@ -313,7 +333,8 @@ private fun ReplyButton( enabled = enabled, modifier = Modifier .fillMaxWidth() - .height(56.dp), + .height(56.dp) + .semantics { contentDescription = replyButtonLabel }, shape = RoundedCornerShape(28.dp) ) { Icon( @@ -323,7 +344,7 @@ private fun ReplyButton( ) Spacer(modifier = Modifier.size(8.dp)) Text( - text = stringResource(R.string.he_support_reply_button), + text = replyButtonLabel, style = MaterialTheme.typography.titleMedium ) } @@ -397,7 +418,8 @@ private fun ReplyBottomSheet( Text( text = stringResource(R.string.he_support_reply_button), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() } ) TextButton( diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt index 3f82fc96aa9f..70f106c36bea 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt @@ -6,29 +6,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,192 +24,113 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime -import org.wordpress.android.support.common.ui.EmptyConversationsView +import org.wordpress.android.support.common.ui.ConversationsListScreen +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.util.generateSampleHESupportConversations -import org.wordpress.android.ui.compose.components.MainTopAppBar -import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun HEConversationsListScreen( snackbarHostState: SnackbarHostState, - conversations: StateFlow>, - isLoadingConversations: StateFlow, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, onConversationClick: (SupportConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, onRefresh: () -> Unit ) { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - MainTopAppBar( - title = stringResource(R.string.he_support_conversations_title), - navigationIcon = NavigationIcons.BackIcon, - onNavigationIconClick = onBackClick, - actions = { - IconButton(onClick = { onCreateNewConversationClick() }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource( - R.string.he_support_new_conversation_content_description - ) - ) - } - } - ) - } - ) { contentPadding -> - ShowConversationsList( - modifier = Modifier.padding(contentPadding), - conversations = conversations, - isLoadingConversations = isLoadingConversations, - onConversationClick = onConversationClick, - onRefresh = onRefresh, - onCreateNewConversationClick = onCreateNewConversationClick - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ShowConversationsList( - modifier: Modifier, - conversations: StateFlow>, - isLoadingConversations: StateFlow, - onConversationClick: (SupportConversation) -> Unit, - onRefresh: () -> Unit, - onCreateNewConversationClick: () -> Unit -) { - val conversationsList by conversations.collectAsState() - val isLoading by isLoadingConversations.collectAsState() val resources = LocalResources.current - - PullToRefreshBox( - isRefreshing = isLoading, + ConversationsListScreen( + title = stringResource(R.string.he_support_conversations_title), + addConversationContentDescription = stringResource(R.string.he_support_new_conversation_content_description), + snackbarHostState = snackbarHostState, + conversations = conversations, + conversationsState = conversationsState, + onBackClick = onBackClick, + onCreateNewConversationClick = onCreateNewConversationClick, onRefresh = onRefresh, - modifier = modifier.fillMaxSize() - ) { - if (conversationsList.isEmpty() && !isLoading) { - EmptyConversationsView( - modifier = Modifier, - onCreateNewConversationClick = onCreateNewConversationClick + conversationListItem = { conversation -> + HEConversationListItem( + conversation = conversation, + resources = resources, + onClick = { onConversationClick(conversation) } ) - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - item { - Spacer(modifier = Modifier.height(16.dp)) - } - - items( - items = conversationsList, - key = { it.id } - ) { conversation -> - ConversationCard( - conversation = conversation, - resources = resources, - onClick = { onConversationClick(conversation) } - ) - Spacer(modifier = Modifier.height(12.dp)) - } - - item { - Spacer(modifier = Modifier.height(16.dp)) - } - } } - } + ) } @Composable -private fun ConversationCard( +private fun HEConversationListItem( conversation: SupportConversation, resources: Resources, onClick: () -> Unit ) { - Card( + Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.weight(1f) ) { - Column( - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = conversation.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - - Text( - text = formatRelativeTime(conversation.lastMessageSentAt, resources), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp) - ) - } - - Spacer(modifier = Modifier.height(4.dp)) + Text( + text = conversation.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) Text( - text = conversation.description, + text = formatRelativeTime(conversation.lastMessageSentAt, resources), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis + modifier = Modifier.padding(start = 8.dp) ) } - Icon( - painter = painterResource(R.drawable.ic_chevron_right_white_24dp), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Text( + modifier = Modifier.padding(top = 4.dp), + text = conversation.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } + + Icon( + painter = painterResource(R.drawable.ic_chevron_right_white_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) } } @Preview(showBackground = true, name = "HE Support Conversations List") @Composable private fun ConversationsScreenPreview() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -236,15 +142,13 @@ private fun ConversationsScreenPreview() { @Preview(showBackground = true, name = "HE Support Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewDark() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -256,15 +160,13 @@ private fun ConversationsScreenPreviewDark() { @Preview(showBackground = true, name = "HE Support Conversations List - WordPress") @Composable private fun ConversationsScreenWordPressPreview() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -276,15 +178,13 @@ private fun ConversationsScreenWordPressPreview() { @Preview(showBackground = true, name = "HE Support Conversations List - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewWordPressDark() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt index fe92e6124474..631ad3ca48e8 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt @@ -2,9 +2,6 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -20,14 +17,18 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,8 +41,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.foundation.text.KeyboardOptions @@ -101,16 +106,11 @@ fun HENewTicketScreen( .fillMaxSize() .padding(contentPadding) .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) + .padding(horizontal = 20.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.he_support_need_help_with), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp) - ) + SectionHeader(text = stringResource(R.string.he_support_need_help_with)) SupportCategory.entries.forEach { category -> CategoryOption( @@ -122,59 +122,69 @@ fun HENewTicketScreen( Spacer(modifier = Modifier.height(12.dp)) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(32.dp)) - Text( - text = stringResource(R.string.he_support_issue_details), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp) - ) + SectionHeader(text = stringResource(R.string.he_support_issue_details)) + val subjectLabel = stringResource(R.string.he_support_subject_label) Text( - text = stringResource(R.string.he_support_subject_label), - style = MaterialTheme.typography.titleMedium, + text = subjectLabel, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( value = subject, onValueChange = { subject = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = subjectLabel }, placeholder = { Text( - text = stringResource(R.string.he_support_subject_placeholder), - color = MaterialTheme.colorScheme.onSurfaceVariant + text = stringResource(R.string.he_support_subject_placeholder) ) }, shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(20.dp)) + val siteAddressLabel = stringResource(R.string.he_support_site_address_label) Text( - text = stringResource(R.string.he_support_site_address_label), - style = MaterialTheme.typography.titleMedium, + text = siteAddressLabel, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( value = siteAddress, onValueChange = { siteAddress = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = siteAddressLabel }, placeholder = { Text( - text = stringResource(R.string.he_support_site_address_placeholder), - color = MaterialTheme.colorScheme.onSurfaceVariant + text = stringResource(R.string.he_support_site_address_placeholder) ) }, shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Uri + ), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) ) Spacer(modifier = Modifier.height(32.dp)) @@ -188,12 +198,7 @@ fun HENewTicketScreen( Spacer(modifier = Modifier.height(32.dp)) - Text( - text = stringResource(R.string.he_support_contact_information), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp) - ) + SectionHeader(text = stringResource(R.string.he_support_contact_information)) ContactInformationCard( userName = userName, @@ -201,42 +206,64 @@ fun HENewTicketScreen( userAvatarUrl = userAvatarUrl ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(24.dp)) } } } +@Composable +private fun SectionHeader( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = modifier + .padding(bottom = 16.dp) + .semantics { heading() } + ) +} + @Composable private fun SendButton( enabled: Boolean, isLoading: Boolean, onClick: () -> Unit ) { - Box( - modifier = Modifier - .fillMaxWidth() - .imePadding() - .padding(16.dp) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp ) { - Button( - onClick = onClick, - enabled = enabled && !isLoading, + Box( modifier = Modifier .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(28.dp) + .imePadding() + .padding(16.dp) ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp - ) - } else { - Text( - text = stringResource(R.string.he_support_send_ticket_button), - style = MaterialTheme.typography.titleMedium - ) + Button( + onClick = onClick, + enabled = enabled && !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.5.dp + ) + } else { + Text( + text = stringResource(R.string.he_support_send_ticket_button), + style = MaterialTheme.typography.labelLarge + ) + } } } } @@ -248,49 +275,54 @@ private fun ContactInformationCard( userEmail: String, userAvatarUrl: String? ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp + ) ) { - Column { + Column( + modifier = Modifier.padding(20.dp) + ) { Text( text = stringResource(R.string.he_support_contact_email_message), - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 20.dp) ) Row( verticalAlignment = Alignment.CenterVertically ) { // Avatar - Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center + Surface( + modifier = Modifier.size(56.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer ) { - if (userAvatarUrl.isNullOrEmpty()) { - Icon( - painter = painterResource(R.drawable.ic_user_white_24dp), - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - RemoteImage( - imageUrl = userAvatarUrl, - fallbackImageRes = R.drawable.ic_user_white_24dp, - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - ) + Box( + contentAlignment = Alignment.Center + ) { + if (userAvatarUrl.isNullOrEmpty()) { + Icon( + painter = painterResource(R.drawable.ic_user_white_24dp), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } else { + RemoteImage( + imageUrl = userAvatarUrl, + fallbackImageRes = R.drawable.ic_user_white_24dp, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + ) + } } } @@ -299,13 +331,13 @@ private fun ContactInformationCard( ) { Text( text = userName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) Text( text = userEmail, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -321,49 +353,60 @@ private fun CategoryOption( isSelected: Boolean, onClick: () -> Unit ) { - Row( + Card( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .border( - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(12.dp) - ) - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(12.dp) - ) - .clickable(onClick = onClick) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + .semantics { contentDescription = label }, + onClick = onClick, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + } + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + }, + modifier = Modifier.size(24.dp) + ) - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp) - ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) - RadioButton( - selected = isSelected, - onClick = onClick, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colorScheme.primary, - unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + RadioButton( + selected = isSelected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) ) - ) + } } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt index 0e27d22fdb3a..171abf010aa4 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -108,10 +108,12 @@ class HESupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name, ) { composable(route = ConversationScreen.List.name) { + val conversationsState by viewModel.conversationsState.collectAsState() + val conversations by viewModel.conversations.collectAsState() HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = viewModel.conversations, - isLoadingConversations = viewModel.isLoadingConversations, + conversations = conversations, + conversationsState = conversationsState, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index 09371a142ffa..a0c54123a968 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -13,6 +13,7 @@ import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper import javax.inject.Inject @HiltViewModel @@ -20,7 +21,8 @@ class HESupportViewModel @Inject constructor( accountStore: AccountStore, private val heSupportRepository: HESupportRepository, appLogWrapper: AppLogWrapper, -) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + networkUtilsWrapper: NetworkUtilsWrapper, +) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { private val _isSendingMessage = MutableStateFlow(false) val isSendingMessage: StateFlow = _isSendingMessage.asStateFlow() diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt index ac4b8fb3f129..ef9a0ca6c911 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt @@ -1,7 +1,7 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,22 +12,28 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material3.OutlinedButton import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -45,11 +51,14 @@ fun TicketMainContentView( .fillMaxWidth() .padding(bottom = 32.dp) ) { + val messageLabel = stringResource(R.string.he_support_message_label) Text( - text = stringResource(R.string.he_support_message_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp) + text = messageLabel, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( @@ -57,19 +66,25 @@ fun TicketMainContentView( onValueChange = { message -> onMessageChanged(message) }, modifier = Modifier .fillMaxWidth() - .height(200.dp), + .height(200.dp) + .semantics { contentDescription = messageLabel }, shape = RoundedCornerShape(12.dp), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), - enabled = enabled + enabled = enabled, + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) ) Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.he_support_screenshots_label), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) + modifier = Modifier + .padding(bottom = 4.dp) + .semantics { heading() } ) Text( @@ -79,21 +94,33 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) - Button( + val addScreenshotsLabel = stringResource(R.string.he_support_add_screenshots_button) + OutlinedButton( onClick = { /* Placeholder for add screenshots */ }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .semantics { contentDescription = addScreenshotsLabel }, shape = RoundedCornerShape(12.dp), - enabled = enabled + enabled = enabled, + border = BorderStroke( + width = 1.dp, + color = if (enabled) { + MaterialTheme.colorScheme.outline + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + } + ) ) { Icon( - imageVector = Icons.Default.CameraAlt, + imageVector = Icons.Default.AddPhotoAlternate, contentDescription = null, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.size(8.dp)) Text( - text = stringResource(R.string.he_support_add_screenshots_button), - style = MaterialTheme.typography.titleMedium + text = addScreenshotsLabel, + style = MaterialTheme.typography.labelLarge ) } @@ -101,44 +128,58 @@ fun TicketMainContentView( Text( text = stringResource(R.string.he_support_app_logs_label), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) + modifier = Modifier + .padding(bottom = 12.dp) + .semantics { heading() } ) - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + val includeLogsLabel = stringResource(R.string.he_support_include_logs_title) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp + ) ) { - Column( - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = stringResource(R.string.he_support_include_logs_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) - ) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = includeLogsLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Text( + text = stringResource(R.string.he_support_include_logs_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.size(16.dp)) - Text( - text = stringResource(R.string.he_support_include_logs_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + Switch( + checked = includeAppLogs, + onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) }, + enabled = enabled, + modifier = Modifier.semantics { + contentDescription = includeLogsLabel + } ) } - - Switch( - checked = includeAppLogs, - onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) }, - enabled = enabled - ) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt index ffcd8543238d..90963637c132 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.he.util +import androidx.compose.ui.text.AnnotatedString import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import java.util.Date @@ -21,21 +22,26 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 1, - text = "Hello! My website has been loading very slowly for the past few days.", + rawText = "", + formattedText = AnnotatedString("Hello! My website has been loading very slowly for " + + "the past few days."), createdAt = Date(oneHourAgo.time - 1800000), authorName = "You", authorIsUser = true ), SupportMessage( id = 2, - text = "Hi there! I'd be happy to help you with that. Can you share your site URL?", + rawText = "", + formattedText = AnnotatedString("Hi there! I'd be happy to help you with that. " + + "Can you share your site URL?"), createdAt = Date(oneHourAgo.time - 900000), authorName = "Support Agent", authorIsUser = false ), SupportMessage( id = 3, - text = "Sure, it's example.wordpress.com", + rawText = "", + formattedText = AnnotatedString("Sure, it's example.wordpress.com"), createdAt = oneHourAgo, authorName = "You", authorIsUser = true @@ -52,14 +58,16 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 4, - text = "I'm trying to install a new plugin but getting an error.", + rawText = "", + formattedText = AnnotatedString("I'm trying to install a new plugin but getting an error."), createdAt = Date(twoDaysAgo.time - 3600000), authorName = "You", authorIsUser = true ), SupportMessage( id = 5, - text = "I can help with that! What's the error message you're seeing?", + rawText = "", + formattedText = AnnotatedString("I can help with that! What's the error message you're seeing?"), createdAt = twoDaysAgo, authorName = "Support Agent", authorIsUser = false @@ -76,7 +84,8 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 6, - text = "I need help setting up my custom domain.", + rawText = "", + formattedText = AnnotatedString("I need help setting up my custom domain."), createdAt = oneWeekAgo, authorName = "You", authorIsUser = true diff --git a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt index df1d63b774bd..1a4f362a28a1 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/logs/ui/LogsListScreen.kt @@ -11,9 +11,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -68,25 +67,18 @@ fun LogsListScreen( modifier = Modifier .fillMaxSize() .padding(contentPadding) - .padding(horizontal = 16.dp) ) { - item { - Spacer(modifier = Modifier.height(16.dp)) - } - items( items = logDays, key = { it.date } ) { logDay -> - LogDayItem( + LogDayListItem( logDay = logDay, onClick = { onLogDayClick(logDay) } ) - Spacer(modifier = Modifier.height(12.dp)) - } - - item { - Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) } } } @@ -94,47 +86,40 @@ fun LogsListScreen( } @Composable -private fun LogDayItem( +private fun LogDayListItem( logDay: LogDay, onClick: () -> Unit ) { - Card( + Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.weight(1f) ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = logDay.displayDate, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.logs_screen_log_count, logDay.logCount), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Icon( - painter = painterResource(R.drawable.ic_chevron_right_white_24dp), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + Text( + text = logDay.displayDate, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = stringResource(R.string.logs_screen_log_count, logDay.logCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } + + Icon( + painter = painterResource(R.drawable.ic_chevron_right_white_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt index e15876f04e60..c9c1a4690ae7 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt @@ -6,18 +6,14 @@ import androidx.compose.foundation.clickable 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.fillMaxSize 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.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -30,6 +26,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -70,27 +69,15 @@ fun SupportScreen( .fillMaxSize() .padding(contentPadding) .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(24.dp)) - - // Support Profile Section - Text( - text = stringResource(R.string.support_screen_profile_section_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Normal - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // User Profile Card or Login Button + // User Profile or Login Button if (isLoggedIn) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), verticalAlignment = Alignment.CenterVertically ) { - // Avatar placeholder Box( modifier = Modifier .size(64.dp) @@ -133,111 +120,113 @@ fun SupportScreen( } } } else { + val loginButtonText = stringResource(R.string.support_screen_login_button) Button( onClick = onLoginClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp) + .semantics { contentDescription = loginButtonText } ) { - Text(text = stringResource(R.string.support_screen_login_button)) + Text(text = loginButtonText) } } - Spacer(modifier = Modifier.height(32.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) // How can we help? Section - Text( - text = stringResource(R.string.support_screen_how_can_we_help_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Normal + SectionHeader( + title = stringResource(R.string.support_screen_how_can_we_help_title) ) - Spacer(modifier = Modifier.height(16.dp)) - - // Support Options Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - Column { - SupportOptionItem( - title = stringResource(R.string.support_screen_help_center_title), - description = stringResource(R.string.support_screen_help_center_description), - onClick = onHelpCenterClick - ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) - if (showAskTheBots) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.surfaceVariant - ) + SupportOptionItem( + title = stringResource(R.string.support_screen_help_center_title), + description = stringResource(R.string.support_screen_help_center_description), + onClick = onHelpCenterClick + ) - SupportOptionItem( - title = stringResource(R.string.support_screen_ask_bots_title), - description = stringResource(R.string.support_screen_ask_bots_description), - onClick = onAskTheBotsClick - ) - } + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) - if (showAskHappinessEngineers) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.surfaceVariant - ) + if (showAskTheBots) { + SupportOptionItem( + title = stringResource(R.string.support_screen_ask_bots_title), + description = stringResource(R.string.support_screen_ask_bots_description), + onClick = onAskTheBotsClick + ) - SupportOptionItem( - title = stringResource(R.string.support_screen_ask_happiness_engineers_title), - description = stringResource(R.string.support_screen_ask_happiness_engineers_description), - onClick = onAskHappinessEngineersClick, - ) - } - } + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) } - Spacer(modifier = Modifier.height(32.dp)) + if (showAskHappinessEngineers) { + SupportOptionItem( + title = stringResource(R.string.support_screen_ask_happiness_engineers_title), + description = stringResource(R.string.support_screen_ask_happiness_engineers_description), + onClick = onAskHappinessEngineersClick, + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } // Diagnostics Section - Text( - text = stringResource(R.string.support_screen_diagnostics_section_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Normal + SectionHeader( + title = stringResource(R.string.support_screen_diagnostics_section_title) ) - Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) - // Application Logs Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - SupportOptionItem( - title = stringResource(R.string.support_screen_application_logs_title), - description = stringResource(R.string.support_screen_application_logs_description), - onClick = onApplicationLogsClick, - ) - } + SupportOptionItem( + title = stringResource(R.string.support_screen_application_logs_title), + description = stringResource(R.string.support_screen_application_logs_description), + onClick = onApplicationLogsClick, + ) - Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) // Version Name Text( text = stringResource(R.string.version_with_name_param, versionName), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 24.dp) ) - - Spacer(modifier = Modifier.height(24.dp)) } } } +@Composable +private fun SectionHeader( + title: String +) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { heading() } + ) +} + @Composable private fun SupportOptionItem( title: String, @@ -248,15 +237,19 @@ private fun SupportOptionItem( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(16.dp), + .padding(horizontal = 16.dp, vertical = 16.dp) + .semantics(mergeDescendants = true) { + contentDescription = "$title. $description" + } ) { Text( text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(4.dp)) Text( + modifier = Modifier.padding(top = 4.dp), text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -267,7 +260,7 @@ private fun SupportOptionItem( @Preview(showBackground = true, name = "Support Screen - Light - Logged In") @Composable private fun SupportScreenPreview() { - AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + AppThemeM3(isDarkTheme = false, isJetpackApp = true) { SupportScreen( userName = "Test user", userEmail = "test.user@gmail.com", @@ -289,7 +282,7 @@ private fun SupportScreenPreview() { @Preview(showBackground = true, name = "Support Screen - Dark - Logged In", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SupportScreenPreviewDark() { - AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + AppThemeM3(isDarkTheme = true, isJetpackApp = true) { SupportScreen( userName = "Test user", userEmail = "test.user@gmail.com", diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/MarkdownUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/MarkdownUtils.kt new file mode 100644 index 000000000000..3b9ef5862dfb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/MarkdownUtils.kt @@ -0,0 +1,161 @@ +package org.wordpress.android.ui.compose.utils + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Emphasis +import org.commonmark.node.Heading +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser + +private const val CODE_BACKGROUND_ALPHA = 0.2f + +/** + * Convert markdown text to Compose AnnotatedString using the CommonMark library. + * + * This provides robust, standards-compliant markdown parsing with support for: + * - **Bold**: `**text**` or `__text__` + * - *Italic*: `*text*` or `_text_` + * - ***Bold + Italic***: `***text***` or `___text___` + * - `Inline Code`: `` `text` `` + * - Links: `[text](url)` + * - Headings: `# Heading` (rendered as bold text) + * - Unordered Lists: `- item` or `* item` + * - Horizontal Rules: `---` or `***` + * - Nested formatting (e.g., `**bold *and italic***`) + * - Proper escape handling + * + * ## Heading Handling + * Headings (# through ######) are rendered as bold text without size differentiation. + * This provides visual emphasis while maintaining a consistent text flow for chat-like UIs. + * + * ## List Handling + * Unordered list items are prefixed with "- " (dash). List formatting is preserved + * with proper indentation and spacing. + * + * ## Link Handling + * Links are styled with underline and color, and include LinkAnnotation.Url annotations + * that automatically handle clicks. When used with Compose Text, links will open in + * the default browser automatically. + * + * ## Security + * This parser applies text styling and link annotations. Links use LinkAnnotation.Url + * which will automatically open URLs in the system browser. + * Safe to use with untrusted user input from support conversations. + * + * @param markdownText The input text with optional markdown syntax + * @return AnnotatedString with applied formatting styles and link annotations + */ +fun markdownToAnnotatedString(markdownText: String): AnnotatedString { + val parser = Parser.builder().build() + val document = parser.parse(markdownText) + + return buildAnnotatedString { + processNode(document) + } +} + +private const val SECTION_DIVIDER_SIZE = 10 + +@Suppress("LongMethod", "CyclomaticComplexMethod") +private fun AnnotatedString.Builder.processNode(node: Node) { + var child = node.firstChild + while (child != null) { + when (child) { + is Text -> append(child.literal) + is Code -> { + val start = length + append(child.literal) + addStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = Color.Gray.copy(alpha = CODE_BACKGROUND_ALPHA) + ), + start, + length + ) + } + is Link -> { + withLink(LinkAnnotation.Url(child.destination)) { + val start = length + processNode(child) + addStyle( + SpanStyle( + color = Color.Blue, + textDecoration = TextDecoration.Underline + ), + start, + length + ) + } + } + is Emphasis -> { + val start = length + processNode(child) + addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, length) + } + is StrongEmphasis -> { + val start = length + processNode(child) + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, length) + } + is Heading -> { + val start = length + processNode(child) + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, length) + // Add newline after heading if it's not the last one + if (child.next != null) { + append("\n\n") + } + } + is BulletList -> { + processNode(child) + // Add newline after list if it's not the last one + if (child.next != null) { + append("\n") + } + } + is ListItem -> { + append("- ") + processNode(child) + // Add newline after list item if it's not the last one + if (child.next != null) { + append("\n") + } + } + is ThematicBreak -> { + append("─".repeat(SECTION_DIVIDER_SIZE)) + // Add newline after horizontal rule if it's not the last one + if (child.next != null) { + append("\n\n") + } + } + is Paragraph -> { + processNode(child) + // Add newline after paragraph if it's not the last one + if (child.next != null) { + append("\n\n") + } + } + is SoftLineBreak -> append("\n") + else -> processNode(child) + } + child = child.next + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 57ea0456bf20..25c6c1c688ff 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5130,6 +5130,8 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> No conversations yet Start a new conversation to get help with your WordPress site or account. Start conversation + You + Support Bot Send diff --git a/WordPress/src/test/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepositoryTest.kt index bacfe7338571..e4a5f401f93b 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepositoryTest.kt @@ -115,9 +115,9 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { assertThat(result?.id).isEqualTo(testChatId) assertThat(result?.messages).hasSize(2) assertThat(result?.messages?.get(0)?.isWrittenByUser).isTrue - assertThat(result?.messages?.get(0)?.text).isEqualTo("User message") + assertThat(result?.messages?.get(0)?.rawText).isEqualTo("User message") assertThat(result?.messages?.get(1)?.isWrittenByUser).isFalse - assertThat(result?.messages?.get(1)?.text).isEqualTo("Bot response") + assertThat(result?.messages?.get(1)?.rawText).isEqualTo("Bot response") assertThat(result?.lastMessage).isEqualTo("Bot response") } @@ -186,9 +186,9 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { assertThat(result).isNotNull assertThat(result?.id).isEqualTo(newChatId) assertThat(result?.messages).hasSize(2) - assertThat(result?.messages?.get(0)?.text).isEqualTo(testMessage) + assertThat(result?.messages?.get(0)?.rawText).isEqualTo(testMessage) assertThat(result?.messages?.get(0)?.isWrittenByUser).isTrue - assertThat(result?.messages?.get(1)?.text).isEqualTo("Bot welcome response") + assertThat(result?.messages?.get(1)?.rawText).isEqualTo("Bot welcome response") assertThat(result?.messages?.get(1)?.isWrittenByUser).isFalse } @@ -241,9 +241,9 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { assertThat(result).isNotNull assertThat(result?.id).isEqualTo(existingChatId) assertThat(result?.messages).hasSize(4) - assertThat(result?.messages?.get(2)?.text).isEqualTo(newMessage) + assertThat(result?.messages?.get(2)?.rawText).isEqualTo(newMessage) assertThat(result?.messages?.get(2)?.isWrittenByUser).isTrue - assertThat(result?.messages?.get(3)?.text).isEqualTo("Bot follow-up response") + assertThat(result?.messages?.get(3)?.rawText).isEqualTo("Bot follow-up response") assertThat(result?.messages?.get(3)?.isWrittenByUser).isFalse assertThat(result?.lastMessage).isEqualTo("Bot follow-up response") } diff --git a/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt index 5f0c80eae315..beaaa821b103 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.aibot.ui +import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -17,6 +18,7 @@ import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage import org.wordpress.android.support.aibot.repository.AIBotSupportRepository import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date @ExperimentalCoroutinesApi @@ -30,6 +32,9 @@ class AIBotSupportViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + private lateinit var viewModel: AIBotSupportViewModel private val testAccessToken = "test_access_token" @@ -50,11 +55,13 @@ class AIBotSupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = AIBotSupportViewModel( accountStore = accountStore, aiBotSupportRepository = aiBotSupportRepository, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) } @@ -128,7 +135,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val selectedConversation = viewModel.selectedConversation.value assertThat(selectedConversation?.messages).isNotEmpty assertThat(selectedConversation?.messages?.any { it.isWrittenByUser }).isTrue - assertThat(selectedConversation?.messages?.any { it.text == "Hello bot" }).isTrue + assertThat(selectedConversation?.messages?.any { it.rawText == "Hello bot" }).isTrue } @Test @@ -245,9 +252,9 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val selectedConversation = viewModel.selectedConversation.value assertThat(selectedConversation?.messages).hasSize(2) assertThat(selectedConversation?.messages?.first()?.isWrittenByUser).isTrue - assertThat(selectedConversation?.messages?.first()?.text).isEqualTo("Hello bot") + assertThat(selectedConversation?.messages?.first()?.rawText).isEqualTo("Hello bot") assertThat(selectedConversation?.messages?.last()?.isWrittenByUser).isFalse - assertThat(selectedConversation?.messages?.last()?.text).isEqualTo("Bot response") + assertThat(selectedConversation?.messages?.last()?.rawText).isEqualTo("Bot response") } @Test @@ -387,7 +394,8 @@ class AIBotSupportViewModelTest : BaseUnitTest() { ): BotMessage { return BotMessage( id = id, - text = text, + rawText = text, + formattedText = AnnotatedString(text), date = Date(), isWrittenByUser = isWrittenByUser ) diff --git a/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt index 90bfe32ae9d1..03f47f2791e9 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt @@ -14,6 +14,8 @@ import org.wordpress.android.fluxc.model.AccountModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.model.Conversation +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel.ConversationsState +import org.wordpress.android.util.NetworkUtilsWrapper @ExperimentalCoroutinesApi class ConversationsSupportViewModelTest : BaseUnitTest() { @@ -23,6 +25,9 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + private lateinit var viewModel: TestConversationsSupportViewModel private val testAccessToken = "test_access_token" @@ -41,10 +46,12 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = TestConversationsSupportViewModel( accountStore = accountStore, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) } @@ -60,7 +67,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { assertThat(viewModel.initRepositoryCalled).isTrue assertThat(viewModel.conversations.value).isEqualTo(testConversations) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Loaded.javaClass) assertThat(viewModel.errorMessage.value).isNull() } @@ -137,7 +144,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { advanceUntilIdle() assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) verify(appLogWrapper).e(any(), any()) } @@ -157,7 +164,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { advanceUntilIdle() assertThat(viewModel.conversations.value).isEqualTo(updatedConversations) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Loaded.javaClass) } @Test @@ -170,7 +177,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { advanceUntilIdle() assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) } // Clear Error Tests @@ -350,8 +357,9 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { private class TestConversationsSupportViewModel( accountStore: AccountStore, - appLogWrapper: AppLogWrapper - ) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + appLogWrapper: AppLogWrapper, + networkUtilsWrapper: NetworkUtilsWrapper + ) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { var initRepositoryCalled = false private var shouldThrowOnInit = false private var shouldThrowOnGetConversations = false diff --git a/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt index 21ca6048c9f7..ffafaf9cbbdc 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.he.repository +import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat @@ -364,7 +365,8 @@ class HESupportRepositoryTest : BaseUnitTest() { private fun uniffi.wp_api.SupportMessage.toSupportMessage(): SupportMessage = SupportMessage( id = this.id.toLong(), - text = this.content, + rawText = this.content, + formattedText = AnnotatedString(this.content), createdAt = this.createdAt, authorName = when (this.author) { is SupportMessageAuthor.User -> (this.author as SupportMessageAuthor.User).v1.displayName diff --git a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt index 18e3d62962d0..acbe3ef97efc 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.he.ui +import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -18,6 +19,7 @@ import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository +import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date @ExperimentalCoroutinesApi @@ -31,6 +33,9 @@ class HESupportViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + private lateinit var viewModel: HESupportViewModel private val testAccessToken = "test_access_token" @@ -51,11 +56,13 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = HESupportViewModel( accountStore = accountStore, heSupportRepository = heSupportRepository, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) } @@ -360,7 +367,8 @@ class HESupportViewModelTest : BaseUnitTest() { ): SupportMessage { return SupportMessage( id = id, - text = text, + rawText = text, + formattedText = AnnotatedString(text), createdAt = Date(), authorName = if (authorIsUser) "User" else "Support", authorIsUser = authorIsUser diff --git a/WordPress/src/test/java/org/wordpress/android/ui/compose/utils/MarkdownUtilsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/compose/utils/MarkdownUtilsTest.kt new file mode 100644 index 000000000000..5db8d5d91c3b --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/compose/utils/MarkdownUtilsTest.kt @@ -0,0 +1,726 @@ +package org.wordpress.android.ui.compose.utils + +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class MarkdownUtilsTest { + @Test + fun `plain text without markdown is unchanged`() { + val input = "This is plain text without any formatting" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo(input) + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `bold text with double asterisks is formatted`() { + val input = "This is **bold** text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is bold text") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].start).isEqualTo(8) + assertThat(result.spanStyles[0].end).isEqualTo(12) + } + + @Test + fun `bold text with double underscores is formatted`() { + val input = "This is __bold__ text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is bold text") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].start).isEqualTo(8) + assertThat(result.spanStyles[0].end).isEqualTo(12) + } + + @Test + fun `italic text with single asterisk is formatted`() { + val input = "This is *italic* text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is italic text") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontStyle).isEqualTo(FontStyle.Italic) + assertThat(result.spanStyles[0].start).isEqualTo(8) + assertThat(result.spanStyles[0].end).isEqualTo(14) + } + + @Test + fun `italic text with single underscore is formatted`() { + val input = "This is _italic_ text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is italic text") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontStyle).isEqualTo(FontStyle.Italic) + assertThat(result.spanStyles[0].start).isEqualTo(8) + assertThat(result.spanStyles[0].end).isEqualTo(14) + } + + @Test + fun `bold and italic text with triple asterisks is formatted`() { + val input = "This is ***bold and italic*** text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is bold and italic text") + // CommonMark applies bold and italic as separate, nested spans + assertThat(result.spanStyles).hasSize(2) + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + val hasItalic = result.spanStyles.any { it.item.fontStyle == FontStyle.Italic } + assertThat(hasBold).isTrue() + assertThat(hasItalic).isTrue() + } + + @Test + fun `bold and italic text with triple underscores is formatted`() { + val input = "This is ___bold and italic___ text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is bold and italic text") + // CommonMark applies bold and italic as separate, nested spans + assertThat(result.spanStyles).hasSize(2) + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + val hasItalic = result.spanStyles.any { it.item.fontStyle == FontStyle.Italic } + assertThat(hasBold).isTrue() + assertThat(hasItalic).isTrue() + } + + @Test + fun `inline code with backticks is formatted`() { + val input = "Use the `code` function" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Use the code function") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontFamily).isEqualTo(FontFamily.Monospace) + assertThat(result.spanStyles[0].item.background).isNotNull() + assertThat(result.spanStyles[0].start).isEqualTo(8) + assertThat(result.spanStyles[0].end).isEqualTo(12) + } + + @Test + fun `multiple markdown formats in same text are all formatted`() { + val input = "This has **bold**, *italic*, and `code` formatting" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This has bold, italic, and code formatting") + assertThat(result.spanStyles).hasSize(3) + + // Bold + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].start).isEqualTo(9) + assertThat(result.spanStyles[0].end).isEqualTo(13) + + // Italic + assertThat(result.spanStyles[1].item.fontStyle).isEqualTo(FontStyle.Italic) + assertThat(result.spanStyles[1].start).isEqualTo(15) + assertThat(result.spanStyles[1].end).isEqualTo(21) + + // Code + assertThat(result.spanStyles[2].item.fontFamily).isEqualTo(FontFamily.Monospace) + assertThat(result.spanStyles[2].start).isEqualTo(27) + assertThat(result.spanStyles[2].end).isEqualTo(31) + } + + @Test + fun `unclosed markdown delimiters are treated as plain text`() { + val input = "This has **unclosed bold text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This has **unclosed bold text") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `empty markdown delimiters are treated as plain text`() { + val input = "This has **** and ____ empty bold" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This has **** and ____ empty bold") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `nested markdown formats are properly supported`() { + val input = "**bold *and italic* combined**" + val result = markdownToAnnotatedString(input) + + // CommonMark properly handles nested formatting + assertThat(result.text).isEqualTo("bold and italic combined") + assertThat(result.spanStyles.size).isGreaterThan(1) + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + val hasItalic = result.spanStyles.any { it.item.fontStyle == FontStyle.Italic } + assertThat(hasBold).isTrue() + assertThat(hasItalic).isTrue() + } + + @Test + fun `multiple bold sections in text are all formatted`() { + val input = "**First** word and **second** word" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("First word and second word") + assertThat(result.spanStyles).hasSize(2) + + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].start).isEqualTo(0) + assertThat(result.spanStyles[0].end).isEqualTo(5) + + assertThat(result.spanStyles[1].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[1].start).isEqualTo(15) + assertThat(result.spanStyles[1].end).isEqualTo(21) + } + + @Test + fun `empty string returns empty annotated string`() { + val input = "" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEmpty() + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `markdown at start of string is formatted`() { + val input = "**Bold** at start" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Bold at start") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].start).isEqualTo(0) + assertThat(result.spanStyles[0].end).isEqualTo(4) + } + + @Test + fun `markdown at end of string is formatted`() { + val input = "At end **bold**" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("At end bold") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].start).isEqualTo(7) + assertThat(result.spanStyles[0].end).isEqualTo(11) + } + + @Test + fun `entire string is markdown formatted`() { + val input = "**Everything is bold**" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Everything is bold") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].start).isEqualTo(0) + assertThat(result.spanStyles[0].end).isEqualTo(18) + } + + @Test + fun `single character markdown formatting works`() { + val input = "Single **a** character" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Single a character") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].start).isEqualTo(7) + assertThat(result.spanStyles[0].end).isEqualTo(8) + } + + // Edge Cases and Escape Characters + + @Test + fun `escaped asterisk is treated as literal`() { + val input = "This is \\*not italic\\* text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is *not italic* text") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `escaped underscore is treated as literal`() { + val input = "This is \\_not italic\\_ text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is _not italic_ text") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `escaped backtick is treated as literal`() { + val input = "This is \\`not code\\` text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is `not code` text") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `escaped backslash is treated as literal`() { + val input = "This is \\\\ a backslash" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is \\ a backslash") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `backslash before non-special character is kept`() { + val input = "This is \\a normal text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is \\a normal text") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `mixed escaped and formatted characters work together`() { + val input = "\\*literal\\* and **bold** text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("*literal* and bold text") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].start).isEqualTo(14) + assertThat(result.spanStyles[0].end).isEqualTo(18) + } + + @Test + fun `unicode characters are preserved correctly`() { + val input = "**Hello 世界** and *emoji 😀*" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Hello 世界 and emoji 😀") + assertThat(result.spanStyles).hasSize(2) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[1].item.fontStyle).isEqualTo(FontStyle.Italic) + } + + @Test + fun `mixed delimiters are not formatted`() { + val input = "This is **not bold__" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("This is **not bold__") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `multiline text with formatting works`() { + val input = "Line 1 **bold**\nLine 2 *italic*\nLine 3 normal" + val result = markdownToAnnotatedString(input) + + // CommonMark adds paragraph separators, so we just verify formatting is applied + assertThat(result.spanStyles.size).isGreaterThanOrEqualTo(2) + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + val hasItalic = result.spanStyles.any { it.item.fontStyle == FontStyle.Italic } + assertThat(hasBold).isTrue() + assertThat(hasItalic).isTrue() + } + + @Test + fun `long text with multiple formats performs correctly`() { + val input = buildString { + repeat(100) { + append("**bold** *italic* `code` ") + } + } + val result = markdownToAnnotatedString(input) + + // Should have 300 spans (100 bold + 100 italic + 100 code) + assertThat(result.spanStyles).hasSize(300) + } + + @Test + fun `escaped characters at end of string are handled`() { + val input = "Text ending with \\*" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Text ending with *") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun `backslash at end of string is preserved`() { + val input = "Text ending with \\" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Text ending with \\") + assertThat(result.spanStyles).isEmpty() + } + + // Link Tests + + @Test + fun `simple link is formatted with underline and color`() { + val input = "Check out [this link](https://example.com)" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Check out this link") + assertThat(result.spanStyles).hasSize(1) + + // Should have color and underline styles combined + val linkStyle = result.spanStyles[0] + assertThat(linkStyle.item.textDecoration).isNotNull() + assertThat(linkStyle.item.color).isNotNull() + } + + @Test + fun `link URL is stored as link annotation`() { + val input = "Visit [example](https://example.com) for more" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Visit example for more") + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + val linkAnnotation = annotations[0].item as LinkAnnotation.Url + assertThat(linkAnnotation.url).isEqualTo("https://example.com") + assertThat(annotations[0].start).isEqualTo(6) + assertThat(annotations[0].end).isEqualTo(13) + } + + @Test + fun `multiple links are all formatted`() { + val input = "See [link1](http://one.com) and [link2](http://two.com)" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("See link1 and link2") + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(2) + val link1 = annotations[0].item as LinkAnnotation.Url + val link2 = annotations[1].item as LinkAnnotation.Url + assertThat(link1.url).isEqualTo("http://one.com") + assertThat(link2.url).isEqualTo("http://two.com") + } + + @Test + fun `link with formatted text inside works`() { + val input = "Click [**bold link**](https://example.com)" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Click bold link") + + // Should have bold, color, and underline + assertThat(result.spanStyles.size).isGreaterThanOrEqualTo(2) + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + assertThat(hasBold).isTrue() + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + val linkAnnotation = annotations[0].item as LinkAnnotation.Url + assertThat(linkAnnotation.url).isEqualTo("https://example.com") + } + + @Test + fun `link at start of string is formatted`() { + val input = "[Start link](https://example.com) here" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Start link here") + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + assertThat(annotations[0].start).isEqualTo(0) + } + + @Test + fun `link at end of string is formatted`() { + val input = "End with [this link](https://example.com)" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("End with this link") + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + assertThat(annotations[0].end).isEqualTo(result.text.length) + } + + @Test + fun `entire string as a link is formatted`() { + val input = "[Everything is a link](https://example.com)" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Everything is a link") + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + assertThat(annotations[0].start).isEqualTo(0) + assertThat(annotations[0].end).isEqualTo(result.text.length) + } + + @Test + fun `link with special characters in URL is preserved`() { + val input = "Go to [search](https://example.com/search?q=test&lang=en)" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Go to search") + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + val linkAnnotation = annotations[0].item as LinkAnnotation.Url + assertThat(linkAnnotation.url).isEqualTo("https://example.com/search?q=test&lang=en") + } + + // Heading Tests + + @Test + fun `heading level 1 is formatted as bold`() { + val input = "# Heading 1" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Heading 1") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + } + + @Test + fun `heading level 2 is formatted as bold`() { + val input = "## Heading 2" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Heading 2") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + } + + @Test + fun `heading level 6 is formatted as bold`() { + val input = "###### Heading 6" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Heading 6") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + } + + @Test + fun `heading with inline formatting preserves both styles`() { + val input = "# Heading with *italic* text" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("Heading with italic text") + // Should have bold for heading and italic for the word + assertThat(result.spanStyles.size).isGreaterThanOrEqualTo(2) + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + val hasItalic = result.spanStyles.any { it.item.fontStyle == FontStyle.Italic } + assertThat(hasBold).isTrue() + assertThat(hasItalic).isTrue() + } + + @Test + fun `multiple headings are all formatted`() { + val input = "# First\n## Second" + val result = markdownToAnnotatedString(input) + + // Both headings should be bold + val boldStyles = result.spanStyles.filter { it.item.fontWeight == FontWeight.Bold } + assertThat(boldStyles.size).isGreaterThanOrEqualTo(2) + } + + @Test + fun `heading followed by paragraph maintains separation`() { + val input = "# Heading\nRegular paragraph" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("Heading") + assertThat(result.text).contains("Regular paragraph") + // Should have bold for heading + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + assertThat(hasBold).isTrue() + } + + @Test + fun `heading with link inside works`() { + val input = "# Heading with [link](https://example.com)" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("Heading with link") + // Should have bold for heading + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + assertThat(hasBold).isTrue() + // Should have link annotation + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + } + + @Test + fun `heading with code inside works`() { + val input = "# Heading with `code`" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("Heading with code") + // Should have bold for heading + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + assertThat(hasBold).isTrue() + // Should have monospace for code + val hasCode = result.spanStyles.any { it.item.fontFamily == FontFamily.Monospace } + assertThat(hasCode).isTrue() + } + + // List Tests + + @Test + fun `simple unordered list is formatted with bullets`() { + val input = "- First item\n- Second item\n- Third item" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("- First item") + assertThat(result.text).contains("- Second item") + assertThat(result.text).contains("- Third item") + } + + @Test + fun `list with asterisk delimiter is formatted`() { + val input = "* Item one\n* Item two" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("- Item one") + assertThat(result.text).contains("- Item two") + } + + @Test + fun `list items with inline formatting preserve styles`() { + val input = "- **Bold item**\n- *Italic item*\n- Item with `code`" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("- Bold item") + assertThat(result.text).contains("- Italic item") + assertThat(result.text).contains("- Item with code") + + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + val hasItalic = result.spanStyles.any { it.item.fontStyle == FontStyle.Italic } + val hasCode = result.spanStyles.any { it.item.fontFamily == FontFamily.Monospace } + + assertThat(hasBold).isTrue() + assertThat(hasItalic).isTrue() + assertThat(hasCode).isTrue() + } + + @Test + fun `list item with link works`() { + val input = "- Check [this link](https://example.com)\n- Another item" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("- Check this link") + assertThat(result.text).contains("- Another item") + + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + val linkAnnotation = annotations[0].item as LinkAnnotation.Url + assertThat(linkAnnotation.url).isEqualTo("https://example.com") + } + + @Test + fun `list followed by paragraph maintains separation`() { + val input = "- List item\n\nRegular paragraph" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("- List item") + assertThat(result.text).contains("Regular paragraph") + } + + @Test + fun `single list item is formatted`() { + val input = "- Only one item" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).isEqualTo("- Only one item") + } + + // Horizontal Rule Tests + + @Test + fun `horizontal rule with dashes is rendered`() { + val input = "Before\n\n---\n\nAfter" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("Before") + assertThat(result.text).contains("──────────") + assertThat(result.text).contains("After") + } + + @Test + fun `horizontal rule with asterisks is rendered`() { + val input = "Text above\n\n***\n\nText below" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("Text above") + assertThat(result.text).contains("──────────") + assertThat(result.text).contains("Text below") + } + + @Test + fun `horizontal rule with underscores is rendered`() { + val input = "Start\n\n___\n\nEnd" + val result = markdownToAnnotatedString(input) + + assertThat(result.text).contains("Start") + assertThat(result.text).contains("──────────") + assertThat(result.text).contains("End") + } + + @Test + fun `multiple horizontal rules are all rendered`() { + val input = "Section 1\n\n---\n\nSection 2\n\n---\n\nSection 3" + val result = markdownToAnnotatedString(input) + + val hrCount = result.text.count { it == '─' } / 10 + assertThat(hrCount).isEqualTo(2) + } + + // Complex Integration Test + + @Test + fun `complex message with all features renders correctly`() { + val input = """ + # Welcome + + Here's a **bold** statement and *italic* text. + + ## Features + + - First **feature** + - Second with [link](https://example.com) + - Third with `code` + + --- + + Visit our site! + """.trimIndent() + + val result = markdownToAnnotatedString(input) + + // Check all elements are present + assertThat(result.text).contains("Welcome") + assertThat(result.text).contains("bold") + assertThat(result.text).contains("italic") + assertThat(result.text).contains("Features") + assertThat(result.text).contains("- First feature") + assertThat(result.text).contains("- Second with link") + assertThat(result.text).contains("- Third with code") + assertThat(result.text).contains("──────────") + assertThat(result.text).contains("Visit our site!") + + // Check styles are applied + val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } + val hasItalic = result.spanStyles.any { it.item.fontStyle == FontStyle.Italic } + val hasCode = result.spanStyles.any { it.item.fontFamily == FontFamily.Monospace } + + assertThat(hasBold).isTrue() + assertThat(hasItalic).isTrue() + assertThat(hasCode).isTrue() + + // Check link annotation + val annotations = result.getLinkAnnotations(0, result.text.length) + assertThat(annotations).hasSize(1) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef912a1b726f..1232ca4b54d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,6 +106,7 @@ wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1' turbine = '1.2.1' +commonmark = '0.24.0' [libraries] airbnb-lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "airbnb-lottie" } @@ -263,6 +264,7 @@ wordpress-utils = { group = "org.wordpress", name = "utils", version.ref = "word automattic-ucrop = { group = "com.automattic", name = "ucrop", version.ref = "automattic-ucrop" } zendesk-support = { group = "com.zendesk", name = "support", version.ref = "zendesk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }