From 80d4ed3e5382ffae66c8145b63be7c4d2b9cd765 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 12:23:15 +0200 Subject: [PATCH 01/81] Adding basic UI --- .../support/he/model/SupportConversation.kt | 11 + .../support/he/model/SupportMessage.kt | 11 + .../support/he/ui/ConversationDetailScreen.kt | 112 +++++++++ .../support/he/ui/ConversationsListScreen.kt | 225 ++++++++++++++++++ .../support/he/ui/HESupportActivity.kt | 90 +++++++ .../support/he/ui/HESupportViewModel.kt | 35 +++ .../support/he/util/ConversationUtils.kt | 82 +++++++ WordPress/src/main/res/values/strings.xml | 4 + 8 files changed, 570 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/model/SupportMessage.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationsListScreen.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/util/ConversationUtils.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt new file mode 100644 index 000000000000..38103bf3221e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.support.he.model + +import java.util.Date + +data class SupportConversation( + val id: Long, + val title: String, + val description: String, + val lastMessageSentAt: Date, + val messages: List +) 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 new file mode 100644 index 000000000000..22f63a846a48 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportMessage.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.support.he.model + +import java.util.Date + +data class SupportMessage( + val id: Long, + val text: String, + val createdAt: Date, + val authorName: String, + val authorIsUser: Boolean +) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt new file mode 100644 index 000000000000..f45d4982fd8c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt @@ -0,0 +1,112 @@ +package org.wordpress.android.support.he.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +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.Text +import androidx.compose.material3.TopAppBar +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.support.he.model.SupportConversation +import org.wordpress.android.support.he.util.generateSampleSupportConversations +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationDetailScreen( + conversation: SupportConversation, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(conversation.title) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(R.string.ai_bot_back_button_content_description) + ) + } + } + ) + } + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.Center + ) { + Text( + text = "Conversation detail screen - Coming soon", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Preview(showBackground = true, name = "HE Support Conversation Detail") +@Composable +private fun ConversationDetailScreenPreview() { + val sampleConversation = generateSampleSupportConversations()[0] + + AppThemeM3(isDarkTheme = false) { + ConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Support Conversation Detail - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ConversationDetailScreenPreviewDark() { + val sampleConversation = generateSampleSupportConversations()[0] + + AppThemeM3(isDarkTheme = true) { + ConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Support Conversation Detail - WordPress") +@Composable +private fun ConversationDetailScreenWordPressPreview() { + val sampleConversation = generateSampleSupportConversations()[0] + + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + ConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Support Conversation Detail - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ConversationDetailScreenPreviewWordPressDark() { + val sampleConversation = generateSampleSupportConversations()[0] + + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + ConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationsListScreen.kt new file mode 100644 index 000000000000..61df8d70620b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationsListScreen.kt @@ -0,0 +1,225 @@ +package org.wordpress.android.support.he.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +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.Add +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.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.util.formatRelativeTime +import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.util.generateSampleSupportConversations +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationsListScreen( + conversations: StateFlow>, + onConversationClick: (SupportConversation) -> Unit, + onBackClick: () -> Unit, + onCreateNewConversationClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.he_support_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.Add, + contentDescription = stringResource( + R.string.he_support_new_conversation_content_description + ) + ) + } + } + ) + } + ) { contentPadding -> + ShowConversationsList( + modifier = Modifier.padding(contentPadding), + conversations = conversations, + onConversationClick = onConversationClick + ) + } +} + +@Composable +private fun ShowConversationsList( + modifier: Modifier, + conversations: StateFlow>, + onConversationClick: (SupportConversation) -> Unit +) { + val conversationsList by conversations.collectAsState() + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Spacer(modifier = Modifier.padding(top = 4.dp)) + } + + items(conversationsList) { conversation -> + ConversationCard( + conversation = conversation, + onClick = { onConversationClick(conversation) } + ) + } + + item { + Spacer(modifier = Modifier.padding(bottom = 4.dp)) + } + } +} + +@Composable +private fun ConversationCard( + conversation: SupportConversation, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = conversation.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + modifier = Modifier.padding(top = 4.dp), + text = conversation.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + modifier = Modifier.padding(top = 8.dp), + text = formatRelativeTime(conversation.lastMessageSentAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Preview(showBackground = true, name = "HE Support Conversations List") +@Composable +private fun ConversationsScreenPreview() { + val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) + + AppThemeM3(isDarkTheme = false) { + ConversationsListScreen( + conversations = sampleConversations.asStateFlow(), + onConversationClick = { }, + onBackClick = { }, + onCreateNewConversationClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Support Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ConversationsScreenPreviewDark() { + val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) + + AppThemeM3(isDarkTheme = true) { + ConversationsListScreen( + conversations = sampleConversations.asStateFlow(), + onConversationClick = { }, + onBackClick = { }, + onCreateNewConversationClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Support Conversations List - WordPress") +@Composable +private fun ConversationsScreenWordPressPreview() { + val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) + + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + ConversationsListScreen( + conversations = sampleConversations.asStateFlow(), + onConversationClick = { }, + onBackClick = { }, + onCreateNewConversationClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Support Conversations List - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ConversationsScreenPreviewWordPressDark() { + val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) + + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + ConversationsListScreen( + conversations = sampleConversations.asStateFlow(), + onConversationClick = { }, + onBackClick = { }, + onCreateNewConversationClick = { } + ) + } +} 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 new file mode 100644 index 000000000000..a08becdb8436 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -0,0 +1,90 @@ +package org.wordpress.android.support.he.ui + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@AndroidEntryPoint +class HESupportActivity : AppCompatActivity() { + private val viewModel by viewModels() + + private lateinit var composeView: ComposeView + private lateinit var navController: NavHostController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + composeView = ComposeView(this) + setContentView( + composeView.apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this.isForceDarkAllowed = false + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + NavigableContent() + } + } + ) + viewModel.init() + } + + private enum class ConversationScreen { + List, + Detail + } + + @Composable + private fun NavigableContent() { + navController = rememberNavController() + + AppThemeM3 { + NavHost( + navController = navController, + startDestination = ConversationScreen.List.name + ) { + composable(route = ConversationScreen.List.name) { + ConversationsListScreen( + conversations = viewModel.conversations, + onConversationClick = { conversation -> + viewModel.selectConversation(conversation) + navController.navigate(ConversationScreen.Detail.name) + }, + onBackClick = { finish() }, + onCreateNewConversationClick = { + viewModel.createNewConversation() + } + ) + } + + composable(route = ConversationScreen.Detail.name) { + val selectedConversation by viewModel.selectedConversation.collectAsState() + selectedConversation?.let { conversation -> + ConversationDetailScreen( + conversation = conversation, + onBackClick = { navController.navigateUp() } + ) + } + } + } + } + } + + companion object { + @JvmStatic + fun createIntent(context: Context): Intent = Intent(context, HESupportActivity::class.java) + } +} 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 new file mode 100644 index 000000000000..5c48d87ca5d7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.support.he.ui + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.util.generateSampleSupportConversations +import javax.inject.Inject + +@HiltViewModel +class HESupportViewModel @Inject constructor() : ViewModel() { + private val _conversations = MutableStateFlow>(emptyList()) + val conversations: StateFlow> = _conversations.asStateFlow() + + private val _selectedConversation = MutableStateFlow(null) + val selectedConversation: StateFlow = _selectedConversation.asStateFlow() + + fun init() { + loadDummyData() + } + + fun selectConversation(conversation: SupportConversation) { + _selectedConversation.value = conversation + } + + fun createNewConversation() { + // Placeholder for creating new conversation - will be implemented when detail screen is ready + } + + private fun loadDummyData() { + _conversations.value = generateSampleSupportConversations() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/util/ConversationUtils.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/ConversationUtils.kt new file mode 100644 index 000000000000..b485228567ed --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/ConversationUtils.kt @@ -0,0 +1,82 @@ +package org.wordpress.android.support.he.util + +import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.model.SupportMessage +import java.util.Date + +@Suppress("MagicNumber", "LongMethod") +fun generateSampleSupportConversations(): List { + val now = Date() + val oneHourAgo = Date(now.time - 3600000) + val twoDaysAgo = Date(now.time - 172800000) + val oneWeekAgo = Date(now.time - 604800000) + + return listOf( + SupportConversation( + id = 1, + title = "Issue with site loading", + description = "My site is loading slowly", + lastMessageSentAt = oneHourAgo, + messages = listOf( + SupportMessage( + id = 1, + text = "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?", + createdAt = Date(oneHourAgo.time - 900000), + authorName = "Support Agent", + authorIsUser = false + ), + SupportMessage( + id = 3, + text = "Sure, it's example.wordpress.com", + createdAt = oneHourAgo, + authorName = "You", + authorIsUser = true + ) + ) + ), + SupportConversation( + id = 2, + title = "Plugin compatibility question", + description = "Question about plugin compatibility", + lastMessageSentAt = twoDaysAgo, + messages = listOf( + SupportMessage( + id = 4, + text = "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?", + createdAt = twoDaysAgo, + authorName = "Support Agent", + authorIsUser = false + ) + ) + ), + SupportConversation( + id = 3, + title = "Custom domain setup", + description = "Help setting up custom domain", + lastMessageSentAt = oneWeekAgo, + messages = listOf( + SupportMessage( + id = 6, + text = "I need help setting up my custom domain.", + createdAt = oneWeekAgo, + authorName = "You", + authorIsUser = true + ) + ) + ) + ) +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index c7a44d234045..ea2f04a6d8c8 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5143,4 +5143,8 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> %1$d days ago %1$d week ago %1$d weeks ago + + + Support Conversations + New conversation From 4836d414b8e8138722ac5e91d37d21d2164da403 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 12:36:44 +0200 Subject: [PATCH 02/81] Renaming --- .../support/he/ui/ConversationDetailScreen.kt | 10 +-- ...Screen.kt => HEConversationsListScreen.kt} | 76 +++++++++++++------ .../support/he/ui/HESupportActivity.kt | 4 +- 3 files changed, 58 insertions(+), 32 deletions(-) rename WordPress/src/main/java/org/wordpress/android/support/he/ui/{ConversationsListScreen.kt => HEConversationsListScreen.kt} (75%) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt index f45d4982fd8c..30762cf4ef77 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt @@ -25,7 +25,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationDetailScreen( +fun HEConversationDetailScreen( conversation: SupportConversation, onBackClick: () -> Unit ) { @@ -65,7 +65,7 @@ private fun ConversationDetailScreenPreview() { val sampleConversation = generateSampleSupportConversations()[0] AppThemeM3(isDarkTheme = false) { - ConversationDetailScreen( + HEConversationDetailScreen( conversation = sampleConversation, onBackClick = { } ) @@ -78,7 +78,7 @@ private fun ConversationDetailScreenPreviewDark() { val sampleConversation = generateSampleSupportConversations()[0] AppThemeM3(isDarkTheme = true) { - ConversationDetailScreen( + HEConversationDetailScreen( conversation = sampleConversation, onBackClick = { } ) @@ -91,7 +91,7 @@ private fun ConversationDetailScreenWordPressPreview() { val sampleConversation = generateSampleSupportConversations()[0] AppThemeM3(isDarkTheme = false, isJetpackApp = false) { - ConversationDetailScreen( + HEConversationDetailScreen( conversation = sampleConversation, onBackClick = { } ) @@ -104,7 +104,7 @@ private fun ConversationDetailScreenPreviewWordPressDark() { val sampleConversation = generateSampleSupportConversations()[0] AppThemeM3(isDarkTheme = true, isJetpackApp = false) { - ConversationDetailScreen( + HEConversationDetailScreen( conversation = sampleConversation, onBackClick = { } ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt similarity index 75% rename from WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationsListScreen.kt rename to WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt index 61df8d70620b..c6eaf718b16b 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt @@ -8,11 +8,13 @@ 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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -26,12 +28,14 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,7 +47,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationsListScreen( +fun HEConversationsListScreen( conversations: StateFlow>, onConversationClick: (SupportConversation) -> Unit, onBackClick: () -> Unit, @@ -130,36 +134,58 @@ private fun ConversationCard( Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.weight(1f) ) { - Text( - text = conversation.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = conversation.title, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 17.sp + ), + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + + Text( + text = formatRelativeTime(conversation.lastMessageSentAt), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(start = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) Text( - modifier = Modifier.padding(top = 4.dp), text = conversation.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), maxLines = 2, overflow = TextOverflow.Ellipsis ) - - Text( - modifier = Modifier.padding(top = 8.dp), - text = formatRelativeTime(conversation.lastMessageSentAt), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + modifier = Modifier.padding(start = 8.dp) + ) } } } @@ -170,7 +196,7 @@ private fun ConversationsScreenPreview() { val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) AppThemeM3(isDarkTheme = false) { - ConversationsListScreen( + HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), onConversationClick = { }, onBackClick = { }, @@ -185,7 +211,7 @@ private fun ConversationsScreenPreviewDark() { val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) AppThemeM3(isDarkTheme = true) { - ConversationsListScreen( + HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), onConversationClick = { }, onBackClick = { }, @@ -200,7 +226,7 @@ private fun ConversationsScreenWordPressPreview() { val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) AppThemeM3(isDarkTheme = false, isJetpackApp = false) { - ConversationsListScreen( + HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), onConversationClick = { }, onBackClick = { }, @@ -215,7 +241,7 @@ private fun ConversationsScreenPreviewWordPressDark() { val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) AppThemeM3(isDarkTheme = true, isJetpackApp = false) { - ConversationsListScreen( + HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), onConversationClick = { }, onBackClick = { }, 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 a08becdb8436..93b4ef20d8a2 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 @@ -57,7 +57,7 @@ class HESupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name ) { composable(route = ConversationScreen.List.name) { - ConversationsListScreen( + HEConversationsListScreen( conversations = viewModel.conversations, onConversationClick = { conversation -> viewModel.selectConversation(conversation) @@ -73,7 +73,7 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.Detail.name) { val selectedConversation by viewModel.selectedConversation.collectAsState() selectedConversation?.let { conversation -> - ConversationDetailScreen( + HEConversationDetailScreen( conversation = conversation, onBackClick = { navController.navigateUp() } ) From 37541d0e23d779698b4bb54cd8313b90c8047951 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 12:41:22 +0200 Subject: [PATCH 03/81] Some styling --- .../he/ui/HEConversationsListScreen.kt | 62 ++++++++----------- 1 file changed, 25 insertions(+), 37 deletions(-) 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 c6eaf718b16b..a93623407f9e 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 @@ -13,8 +13,6 @@ 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.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -24,18 +22,17 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,6 +40,8 @@ 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.util.generateSampleSupportConversations +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) @@ -55,16 +54,10 @@ fun HEConversationsListScreen( ) { Scaffold( topBar = { - TopAppBar( - title = { Text(stringResource(R.string.he_support_conversations_title)) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(R.string.ai_bot_back_button_content_description) - ) - } - }, + MainTopAppBar( + title = stringResource(R.string.he_support_conversations_title), + navigationIcon = NavigationIcons.BackIcon, + onNavigationIconClick = onBackClick, actions = { IconButton(onClick = { onCreateNewConversationClick() }) { Icon( @@ -97,22 +90,25 @@ private fun ShowConversationsList( LazyColumn( modifier = modifier .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .padding(horizontal = 16.dp) ) { item { - Spacer(modifier = Modifier.padding(top = 4.dp)) + Spacer(modifier = Modifier.height(16.dp)) } - items(conversationsList) { conversation -> + items( + items = conversationsList, + key = { it.id } + ) { conversation -> ConversationCard( conversation = conversation, onClick = { onConversationClick(conversation) } ) + Spacer(modifier = Modifier.height(12.dp)) } item { - Spacer(modifier = Modifier.padding(bottom = 4.dp)) + Spacer(modifier = Modifier.height(16.dp)) } } } @@ -126,10 +122,10 @@ private fun ConversationCard( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface - ) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Row( modifier = Modifier @@ -147,11 +143,8 @@ private fun ConversationCard( ) { Text( text = conversation.title, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 17.sp - ), - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) @@ -159,10 +152,8 @@ private fun ConversationCard( Text( text = formatRelativeTime(conversation.lastMessageSentAt), - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 13.sp - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp) ) } @@ -171,20 +162,17 @@ private fun ConversationCard( Text( text = conversation.description, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 15.sp - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis ) } Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + painter = painterResource(R.drawable.ic_chevron_right_white_24dp), contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), - modifier = Modifier.padding(start = 8.dp) + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } From 51b01a755b9ffc587542ea00093767e51ad416bb Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 12:44:28 +0200 Subject: [PATCH 04/81] Renaming and dummy data --- .../support/he/ui/ConversationDetailScreen.kt | 10 +++++----- .../he/ui/HEConversationsListScreen.kt | 10 +++++----- .../support/he/ui/HESupportViewModel.kt | 4 ++-- ...rsationUtils.kt => HEConversationUtils.kt} | 19 ++++++++++++------- 4 files changed, 24 insertions(+), 19 deletions(-) rename WordPress/src/main/java/org/wordpress/android/support/he/util/{ConversationUtils.kt => HEConversationUtils.kt} (71%) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt index 30762cf4ef77..cbfd0fbaf86b 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import org.wordpress.android.R import org.wordpress.android.support.he.model.SupportConversation -import org.wordpress.android.support.he.util.generateSampleSupportConversations +import org.wordpress.android.support.he.util.generateSampleHESupportConversations import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @@ -62,7 +62,7 @@ fun HEConversationDetailScreen( @Preview(showBackground = true, name = "HE Support Conversation Detail") @Composable private fun ConversationDetailScreenPreview() { - val sampleConversation = generateSampleSupportConversations()[0] + val sampleConversation = generateSampleHESupportConversations()[0] AppThemeM3(isDarkTheme = false) { HEConversationDetailScreen( @@ -75,7 +75,7 @@ private fun ConversationDetailScreenPreview() { @Preview(showBackground = true, name = "HE Support Conversation Detail - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationDetailScreenPreviewDark() { - val sampleConversation = generateSampleSupportConversations()[0] + val sampleConversation = generateSampleHESupportConversations()[0] AppThemeM3(isDarkTheme = true) { HEConversationDetailScreen( @@ -88,7 +88,7 @@ private fun ConversationDetailScreenPreviewDark() { @Preview(showBackground = true, name = "HE Support Conversation Detail - WordPress") @Composable private fun ConversationDetailScreenWordPressPreview() { - val sampleConversation = generateSampleSupportConversations()[0] + val sampleConversation = generateSampleHESupportConversations()[0] AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HEConversationDetailScreen( @@ -101,7 +101,7 @@ private fun ConversationDetailScreenWordPressPreview() { @Preview(showBackground = true, name = "HE Support Conversation Detail - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationDetailScreenPreviewWordPressDark() { - val sampleConversation = generateSampleSupportConversations()[0] + val sampleConversation = generateSampleHESupportConversations()[0] AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationDetailScreen( 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 a93623407f9e..e0224c457a66 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 @@ -39,7 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow 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.util.generateSampleSupportConversations +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 @@ -181,7 +181,7 @@ private fun ConversationCard( @Preview(showBackground = true, name = "HE Support Conversations List") @Composable private fun ConversationsScreenPreview() { - val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) + val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) AppThemeM3(isDarkTheme = false) { HEConversationsListScreen( @@ -196,7 +196,7 @@ private fun ConversationsScreenPreview() { @Preview(showBackground = true, name = "HE Support Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewDark() { - val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) + val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) AppThemeM3(isDarkTheme = true) { HEConversationsListScreen( @@ -211,7 +211,7 @@ private fun ConversationsScreenPreviewDark() { @Preview(showBackground = true, name = "HE Support Conversations List - WordPress") @Composable private fun ConversationsScreenWordPressPreview() { - val sampleConversations = MutableStateFlow(generateSampleSupportConversations()) + val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HEConversationsListScreen( @@ -226,7 +226,7 @@ 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(generateSampleSupportConversations()) + val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationsListScreen( 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 5c48d87ca5d7..a94cd7dcdb95 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 @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.wordpress.android.support.he.model.SupportConversation -import org.wordpress.android.support.he.util.generateSampleSupportConversations +import org.wordpress.android.support.he.util.generateSampleHESupportConversations import javax.inject.Inject @HiltViewModel @@ -30,6 +30,6 @@ class HESupportViewModel @Inject constructor() : ViewModel() { } private fun loadDummyData() { - _conversations.value = generateSampleSupportConversations() + _conversations.value = generateSampleHESupportConversations() } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/util/ConversationUtils.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt similarity index 71% rename from WordPress/src/main/java/org/wordpress/android/support/he/util/ConversationUtils.kt rename to WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt index b485228567ed..ffcd8543238d 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/util/ConversationUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt @@ -5,7 +5,7 @@ import org.wordpress.android.support.he.model.SupportMessage import java.util.Date @Suppress("MagicNumber", "LongMethod") -fun generateSampleSupportConversations(): List { +fun generateSampleHESupportConversations(): List { val now = Date() val oneHourAgo = Date(now.time - 3600000) val twoDaysAgo = Date(now.time - 172800000) @@ -14,8 +14,9 @@ fun generateSampleSupportConversations(): List { return listOf( SupportConversation( id = 1, - title = "Issue with site loading", - description = "My site is loading slowly", + title = "Login Issues with Two-Factor Authentication Not Working on Mobile App", + description = "I'm having trouble logging into my account. The two-factor authentication code " + + "doesn't seem to be working properly when I try to access my site from the mobile app.", lastMessageSentAt = oneHourAgo, messages = listOf( SupportMessage( @@ -43,8 +44,10 @@ fun generateSampleSupportConversations(): List { ), SupportConversation( id = 2, - title = "Plugin compatibility question", - description = "Question about plugin compatibility", + title = "Website Performance Issues After Installing New Theme and Plugins", + description = "After updating my theme and installing several new plugins for my e-commerce " + + "store, I've noticed significant slowdowns and occasional timeout errors affecting customer " + + "experience.", lastMessageSentAt = twoDaysAgo, messages = listOf( SupportMessage( @@ -65,8 +68,10 @@ fun generateSampleSupportConversations(): List { ), SupportConversation( id = 3, - title = "Custom domain setup", - description = "Help setting up custom domain", + title = "Need Help Configuring Custom Domain DNS Settings and Email Forwarding", + description = "I recently purchased a custom domain and need assistance with proper DNS " + + "configuration, SSL certificate setup, and setting up professional email forwarding for my " + + "business site.", lastMessageSentAt = oneWeekAgo, messages = listOf( SupportMessage( From 6c83ae3da2ca8a1969457ddd7c1d1dea1d63c385 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 12:46:59 +0200 Subject: [PATCH 05/81] Using proper "new conversation icon" --- .../android/support/aibot/ui/ConversationsListScreen.kt | 4 ++-- .../android/support/he/ui/HEConversationsListScreen.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt index 1b3e69123f41..fc5ec27dfd64 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt @@ -12,7 +12,7 @@ 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.Add +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -66,7 +66,7 @@ fun ConversationsListScreen( actions = { IconButton(onClick = { onCreateNewConversationClick() }) { Icon( - imageVector = Icons.Default.Add, + imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.ai_bot_new_conversation_content_description) ) } 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 e0224c457a66..0e26c2c46a55 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 @@ -13,7 +13,7 @@ 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.material.icons.filled.Edit import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -61,7 +61,7 @@ fun HEConversationsListScreen( actions = { IconButton(onClick = { onCreateNewConversationClick() }) { Icon( - imageVector = Icons.Default.Add, + imageVector = Icons.Default.Edit, contentDescription = stringResource( R.string.he_support_new_conversation_content_description ) From 8d7ea50319610750319153f4d13a24ab307c4f26 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 13:01:43 +0200 Subject: [PATCH 06/81] Conversation details screen --- .../support/he/ui/ConversationDetailScreen.kt | 112 ------- .../he/ui/HEConversationDetailScreen.kt | 291 ++++++++++++++++++ WordPress/src/main/res/values/strings.xml | 5 + 3 files changed, 296 insertions(+), 112 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt deleted file mode 100644 index cbfd0fbaf86b..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/ConversationDetailScreen.kt +++ /dev/null @@ -1,112 +0,0 @@ -package org.wordpress.android.support.he.ui - -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -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.Text -import androidx.compose.material3.TopAppBar -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.support.he.model.SupportConversation -import org.wordpress.android.support.he.util.generateSampleHESupportConversations -import org.wordpress.android.ui.compose.theme.AppThemeM3 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HEConversationDetailScreen( - conversation: SupportConversation, - onBackClick: () -> Unit -) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(conversation.title) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(R.string.ai_bot_back_button_content_description) - ) - } - } - ) - } - ) { contentPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding), - contentAlignment = Alignment.Center - ) { - Text( - text = "Conversation detail screen - Coming soon", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Preview(showBackground = true, name = "HE Support Conversation Detail") -@Composable -private fun ConversationDetailScreenPreview() { - val sampleConversation = generateSampleHESupportConversations()[0] - - AppThemeM3(isDarkTheme = false) { - HEConversationDetailScreen( - conversation = sampleConversation, - onBackClick = { } - ) - } -} - -@Preview(showBackground = true, name = "HE Support Conversation Detail - Dark", uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun ConversationDetailScreenPreviewDark() { - val sampleConversation = generateSampleHESupportConversations()[0] - - AppThemeM3(isDarkTheme = true) { - HEConversationDetailScreen( - conversation = sampleConversation, - onBackClick = { } - ) - } -} - -@Preview(showBackground = true, name = "HE Support Conversation Detail - WordPress") -@Composable -private fun ConversationDetailScreenWordPressPreview() { - val sampleConversation = generateSampleHESupportConversations()[0] - - AppThemeM3(isDarkTheme = false, isJetpackApp = false) { - HEConversationDetailScreen( - conversation = sampleConversation, - onBackClick = { } - ) - } -} - -@Preview(showBackground = true, name = "HE Support Conversation Detail - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun ConversationDetailScreenPreviewWordPressDark() { - val sampleConversation = generateSampleHESupportConversations()[0] - - AppThemeM3(isDarkTheme = true, isJetpackApp = false) { - HEConversationDetailScreen( - conversation = sampleConversation, - onBackClick = { } - ) - } -} 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 new file mode 100644 index 000000000000..e241e4fbacc7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt @@ -0,0 +1,291 @@ +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.layout.Arrangement +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.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 HEConversationDetailScreen( + conversation: SupportConversation, + onBackClick: () -> Unit +) { + val listState = rememberLazyListState() + + Scaffold( + topBar = { + MainTopAppBar( + title = "", + navigationIcon = NavigationIcons.BackIcon, + onNavigationIconClick = onBackClick + ) + }, + bottomBar = { + ReplyButton( + onClick = { /* Placeholder for reply functionality */ } + ) + } + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + ConversationHeader( + messageCount = conversation.messages.size, + lastUpdated = formatRelativeTime(conversation.lastMessageSentAt) + ) + } + + item { + ConversationTitleCard(title = conversation.title) + } + + items( + items = conversation.messages, + key = { it.id } + ) { message -> + MessageItem( + authorName = message.authorName, + messageText = message.text, + timestamp = formatRelativeTime(message.createdAt), + isUserMessage = message.authorIsUser + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun ConversationHeader( + messageCount: Int, + lastUpdated: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_comment_white_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.he_support_message_count, messageCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = stringResource(R.string.he_support_last_updated, lastUpdated), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ConversationTitleCard(title: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } +} + +@Composable +private fun MessageItem( + authorName: String, + messageText: String, + timestamp: String, + isUserMessage: Boolean +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (isUserMessage) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.20f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + shape = RoundedCornerShape(8.dp) + ) + .padding(16.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = authorName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isUserMessage) FontWeight.Bold else FontWeight.Normal, + color = if (isUserMessage) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + + Text( + text = timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = messageText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun ReplyButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(28.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.he_support_reply_button), + style = MaterialTheme.typography.titleMedium + ) + } + } +} + +@Preview(showBackground = true, name = "HE Conversation Detail") +@Composable +private fun HEConversationDetailScreenPreview() { + val sampleConversation = generateSampleHESupportConversations()[0] + + AppThemeM3(isDarkTheme = false) { + HEConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Conversation Detail - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun HEConversationDetailScreenPreviewDark() { + val sampleConversation = generateSampleHESupportConversations()[0] + + AppThemeM3(isDarkTheme = true) { + HEConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Conversation Detail - WordPress") +@Composable +private fun HEConversationDetailScreenWordPressPreview() { + val sampleConversation = generateSampleHESupportConversations()[0] + + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + HEConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} + +@Preview(showBackground = true, name = "HE Conversation Detail - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun HEConversationDetailScreenPreviewWordPressDark() { + val sampleConversation = generateSampleHESupportConversations()[0] + + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + HEConversationDetailScreen( + conversation = sampleConversation, + onBackClick = { } + ) + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index ea2f04a6d8c8..3d3a8f97487b 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5147,4 +5147,9 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Support Conversations New conversation + + + %1$d Messages + Last updated %1$s + Reply From a0a146b66b030b808d60cbbf197ef8de360984ad Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 13:31:04 +0200 Subject: [PATCH 07/81] Creating the reply bottomsheet --- .../he/ui/HEConversationDetailScreen.kt | 469 +++++++++++++++++- WordPress/src/main/res/values/strings.xml | 8 + 2 files changed, 475 insertions(+), 2 deletions(-) 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 e241e4fbacc7..df1d006dc511 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 @@ -18,13 +18,25 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -47,6 +59,9 @@ fun HEConversationDetailScreen( onBackClick: () -> Unit ) { val listState = rememberLazyListState() + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + var showBottomSheet by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -58,7 +73,9 @@ fun HEConversationDetailScreen( }, bottomBar = { ReplyButton( - onClick = { /* Placeholder for reply functionality */ } + onClick = { + showBottomSheet = true + } ) } ) { contentPadding -> @@ -98,6 +115,27 @@ fun HEConversationDetailScreen( } } } + + if (showBottomSheet) { + ReplyBottomSheet( + sheetState = sheetState, + onDismiss = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + showBottomSheet = false + } + }, + onSend = { message, includeAppLogs -> + /* Placeholder for send functionality */ + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + showBottomSheet = false + } + } + ) + } } @Composable @@ -142,7 +180,7 @@ private fun ConversationTitleCard(title: String) { Box( modifier = Modifier .fillMaxWidth() - .padding(20.dp) + .padding(16.dp) ) { Text( text = title, @@ -238,6 +276,433 @@ private fun ReplyButton(onClick: () -> Unit) { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReplyBottomSheet( + sheetState: androidx.compose.material3.SheetState, + onDismiss: () -> Unit, + onSend: (String, Boolean) -> Unit +) { + var messageText by remember { mutableStateOf("") } + var includeAppLogs by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = stringResource(R.string.he_support_reply_button), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + TextButton( + onClick = { onSend(messageText, includeAppLogs) }, + enabled = messageText.isNotBlank() + ) { + Text( + text = stringResource(R.string.he_support_send_button), + style = MaterialTheme.typography.titleMedium + ) + } + } + + Text( + text = stringResource(R.string.he_support_message_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = messageText, + onValueChange = { messageText = it }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_screenshots_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Text( + text = stringResource(R.string.he_support_screenshots_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Button( + onClick = { /* Placeholder for add screenshots */ }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + 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 + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_app_logs_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.he_support_include_logs_title), + style = MaterialTheme.typography.titleMedium, + 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 + ) + } + + Switch( + checked = includeAppLogs, + onCheckedChange = { includeAppLogs = it } + ) + } + } + } +} + +@Preview(showBackground = true, name = "HE Reply Bottom Sheet Content") +@Composable +private fun ReplyBottomSheetPreview() { + var messageText by remember { mutableStateOf("") } + var includeAppLogs by remember { mutableStateOf(false) } + + AppThemeM3(isDarkTheme = false) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { }) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = stringResource(R.string.he_support_reply_button), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + TextButton( + onClick = { }, + enabled = messageText.isNotBlank() + ) { + Text( + text = stringResource(R.string.he_support_send_button), + style = MaterialTheme.typography.titleMedium + ) + } + } + + Text( + text = stringResource(R.string.he_support_message_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = messageText, + onValueChange = { messageText = it }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_screenshots_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Text( + text = stringResource(R.string.he_support_screenshots_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Button( + onClick = { }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + 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 + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_app_logs_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.he_support_include_logs_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Switch( + checked = includeAppLogs, + onCheckedChange = { includeAppLogs = it } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.he_support_include_logs_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Preview(showBackground = true, name = "HE Reply Bottom Sheet Content - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ReplyBottomSheetPreviewDark() { + var messageText by remember { mutableStateOf("") } + var includeAppLogs by remember { mutableStateOf(false) } + + AppThemeM3(isDarkTheme = true) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { }) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = stringResource(R.string.he_support_reply_button), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + TextButton( + onClick = { }, + enabled = messageText.isNotBlank() + ) { + Text( + text = stringResource(R.string.he_support_send_button), + style = MaterialTheme.typography.titleMedium + ) + } + } + + Text( + text = stringResource(R.string.he_support_message_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = messageText, + onValueChange = { messageText = it }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_screenshots_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Text( + text = stringResource(R.string.he_support_screenshots_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Button( + onClick = { }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + 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 + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_app_logs_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.he_support_include_logs_title), + style = MaterialTheme.typography.titleMedium, + 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 + ) + } + + Switch( + checked = includeAppLogs, + onCheckedChange = { includeAppLogs = it } + ) + } + } + } +} + @Preview(showBackground = true, name = "HE Conversation Detail") @Composable private fun HEConversationDetailScreenPreview() { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 3d3a8f97487b..c874830b4d38 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5152,4 +5152,12 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> %1$d Messages Last updated %1$s Reply + Send + Message + Screenshots (Optional) + Adding screenshots can help us understand and resolve your issue faster. + Add Screenshots + Application Logs (Optional) + Include application logs + Including logs can help our team investigate issues. Logs may contain recent app activity. From eebc0ab5d8671f08ec710a23b74274e9a8106c4b Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 14:13:33 +0200 Subject: [PATCH 08/81] Linking to the support screen --- WordPress/src/main/AndroidManifest.xml | 4 ++++ .../android/support/main/ui/SupportActivity.kt | 11 +++++++++++ .../android/support/main/ui/SupportViewModel.kt | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 6629ee80ecdf..db7bb513ede3 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -444,6 +444,10 @@ android:theme="@style/WordPress.NoActionBar" android:label="@string/support_screen_title"/> + + { navigateToLogin() } + + SupportViewModel.NavigationEvent.NavigateToAskHappinessEngineers -> { + navigateToAskTheHappinessEngineers() + } } } } @@ -85,6 +90,12 @@ class SupportActivity : AppCompatActivity() { ) } + private fun navigateToAskTheHappinessEngineers() { + startActivity( + HESupportActivity.Companion.createIntent(this) + ) + } + private fun navigateToLogin() { if (BuildConfig.IS_JETPACK_APP) { ActivityLauncher.showSignInForResultJetpackOnly(this) diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index ab76f30013d6..61c82e06fa81 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -23,6 +23,7 @@ class SupportViewModel @Inject constructor( sealed class NavigationEvent { data class NavigateToAskTheBots(val accessToken: String, val userName: String) : NavigationEvent() data object NavigateToLogin : NavigationEvent() + data object NavigateToAskHappinessEngineers : NavigationEvent() } data class UserInfo( @@ -88,7 +89,9 @@ class SupportViewModel @Inject constructor( } fun onAskHappinessEngineersClick() { - // Navigate to Happiness Engineers contact + viewModelScope.launch { + _navigationEvents.emit(NavigationEvent.NavigateToAskHappinessEngineers) + } } fun onApplicationLogsClick() { From 37676c88b66ed4877915ca34ad2351979caa1cb9 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 14:18:11 +0200 Subject: [PATCH 09/81] bottomsheet fix --- .../android/support/he/ui/HEConversationDetailScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 df1d006dc511..9826b361d2c6 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 @@ -59,7 +59,7 @@ fun HEConversationDetailScreen( onBackClick: () -> Unit ) { val listState = rememberLazyListState() - val sheetState = rememberModalBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() var showBottomSheet by remember { mutableStateOf(false) } From 703f4c434ba82f7adc523edfc22afd2c61b1fc05 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 11:53:54 +0200 Subject: [PATCH 10/81] Mov navigation form activity to viewmodel --- .../support/he/ui/HESupportActivity.kt | 29 ++++++++++++++++--- .../support/he/ui/HESupportViewModel.kt | 26 +++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) 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 93b4ef20d8a2..c8b2e764d179 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 @@ -11,11 +11,15 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 @AndroidEntryPoint @@ -27,6 +31,8 @@ class HESupportActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + viewModel.init() + observeNavigationEvents() composeView = ComposeView(this) setContentView( composeView.apply { @@ -39,7 +45,23 @@ class HESupportActivity : AppCompatActivity() { } } ) - viewModel.init() + } + + private fun observeNavigationEvents() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigationEvents.collect { event -> + when (event) { + is HESupportViewModel.NavigationEvent.NavigateToConversationDetail -> { + navController.navigate(ConversationScreen.Detail.name) + } + HESupportViewModel.NavigationEvent.NavigateBack -> { + navController.navigateUp() + } + } + } + } + } } private enum class ConversationScreen { @@ -60,8 +82,7 @@ class HESupportActivity : AppCompatActivity() { HEConversationsListScreen( conversations = viewModel.conversations, onConversationClick = { conversation -> - viewModel.selectConversation(conversation) - navController.navigate(ConversationScreen.Detail.name) + viewModel.onConversationClick(conversation) }, onBackClick = { finish() }, onCreateNewConversationClick = { @@ -75,7 +96,7 @@ class HESupportActivity : AppCompatActivity() { selectedConversation?.let { conversation -> HEConversationDetailScreen( conversation = conversation, - onBackClick = { navController.navigateUp() } + onBackClick = { viewModel.onBackFromDetailClick() } ) } } 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 a94cd7dcdb95..3b313d36db59 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 @@ -1,28 +1,50 @@ package org.wordpress.android.support.he.ui import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.util.generateSampleHESupportConversations import javax.inject.Inject @HiltViewModel class HESupportViewModel @Inject constructor() : ViewModel() { + sealed class NavigationEvent { + data class NavigateToConversationDetail(val conversation: SupportConversation) : NavigationEvent() + data object NavigateBack : NavigationEvent() + } + private val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations.asStateFlow() private val _selectedConversation = MutableStateFlow(null) val selectedConversation: StateFlow = _selectedConversation.asStateFlow() + private val _navigationEvents = MutableSharedFlow() + val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + fun init() { loadDummyData() } - fun selectConversation(conversation: SupportConversation) { - _selectedConversation.value = conversation + fun onConversationClick(conversation: SupportConversation) { + viewModelScope.launch { + _selectedConversation.value = conversation + _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail(conversation)) + } + } + + fun onBackFromDetailClick() { + viewModelScope.launch { + _navigationEvents.emit(NavigationEvent.NavigateBack) + } } fun createNewConversation() { From f6be7fd0404b29dbf006857fa26b21d9e9294c19 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 12:08:42 +0200 Subject: [PATCH 11/81] Adding create ticket screen --- .../support/he/ui/HENewTicketScreen.kt | 270 ++++++++++++++++++ .../support/he/ui/HESupportActivity.kt | 16 +- .../support/he/ui/HESupportViewModel.kt | 5 +- WordPress/src/main/res/values/strings.xml | 15 + 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt 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 new file mode 100644 index 000000000000..5b8904d7c2a7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt @@ -0,0 +1,270 @@ +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.Arrangement +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.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.MainTopAppBar +import org.wordpress.android.ui.compose.components.NavigationIcons +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.compose.theme.neutral + +enum class SupportCategory(val icon: ImageVector, val labelRes: Int) { + APPLICATION(Icons.Default.PhoneAndroid, R.string.he_support_category_application), + JETPACK_CONNECTION(Icons.Default.Settings, R.string.he_support_category_jetpack_connection), + SITE_MANAGEMENT(Icons.Default.Language, R.string.he_support_category_site_management), + BILLING(Icons.Default.CreditCard, R.string.he_support_category_billing), + TECHNICAL_ISSUES(Icons.Default.Settings, R.string.he_support_category_technical_issues), + OTHER(Icons.AutoMirrored.Filled.Help, R.string.he_support_category_other) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HENewTicketScreen( + onBackClick: () -> Unit, + onSubmit: (category: SupportCategory, subject: String, siteAddress: String) -> Unit +) { + var selectedCategory by remember { mutableStateOf(null) } + var subject by remember { mutableStateOf("") } + var siteAddress by remember { mutableStateOf("") } + + Scaffold( + topBar = { + MainTopAppBar( + title = stringResource(R.string.he_support_contact_support_title), + navigationIcon = NavigationIcons.BackIcon, + onNavigationIconClick = onBackClick + ) + } + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.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) + ) + + SupportCategory.entries.forEach { category -> + CategoryOption( + icon = category.icon, + label = stringResource(category.labelRes), + isSelected = selectedCategory == category, + onClick = { selectedCategory = category } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_issue_details), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Text( + text = stringResource(R.string.he_support_subject_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.he_support_subject_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_site_address_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = siteAddress, + onValueChange = { siteAddress = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.he_support_site_address_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun CategoryOption( + icon: ImageVector, + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + 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 + ) { + Box( + modifier = Modifier + .size(40.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + 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 + ) + ) + } +} + +@Preview(showBackground = true, name = "HE New Ticket Screen") +@Composable +private fun HENewTicketScreenPreview() { + AppThemeM3(isDarkTheme = false) { + HENewTicketScreen( + onBackClick = { }, + onSubmit = { _, _, _ -> } + ) + } +} + +@Preview(showBackground = true, name = "HE New Ticket Screen - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun HENewTicketScreenPreviewDark() { + AppThemeM3(isDarkTheme = true) { + HENewTicketScreen( + onBackClick = { }, + onSubmit = { _, _, _ -> } + ) + } +} + +@Preview(showBackground = true, name = "HE New Ticket Screen - WordPress") +@Composable +private fun HENewTicketScreenWordPressPreview() { + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + HENewTicketScreen( + onBackClick = { }, + onSubmit = { _, _, _ -> } + ) + } +} + +@Preview(showBackground = true, name = "HE New Ticket Screen - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun HENewTicketScreenPreviewWordPressDark() { + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + HENewTicketScreen( + onBackClick = { }, + onSubmit = { _, _, _ -> } + ) + } +} 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 c8b2e764d179..900f2c6997c3 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 @@ -55,6 +55,9 @@ class HESupportActivity : AppCompatActivity() { is HESupportViewModel.NavigationEvent.NavigateToConversationDetail -> { navController.navigate(ConversationScreen.Detail.name) } + HESupportViewModel.NavigationEvent.NavigateToNewTicket -> { + navController.navigate(ConversationScreen.NewTicket.name) + } HESupportViewModel.NavigationEvent.NavigateBack -> { navController.navigateUp() } @@ -66,7 +69,8 @@ class HESupportActivity : AppCompatActivity() { private enum class ConversationScreen { List, - Detail + Detail, + NewTicket } @Composable @@ -100,6 +104,16 @@ class HESupportActivity : AppCompatActivity() { ) } } + + composable(route = ConversationScreen.NewTicket.name) { + HENewTicketScreen( + onBackClick = { viewModel.onBackFromDetailClick() }, + onSubmit = { category, subject, siteAddress -> + // TODO: Handle ticket submission + viewModel.onBackFromDetailClick() + } + ) + } } } } 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 3b313d36db59..b25e42f44bb3 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 @@ -18,6 +18,7 @@ import javax.inject.Inject class HESupportViewModel @Inject constructor() : ViewModel() { sealed class NavigationEvent { data class NavigateToConversationDetail(val conversation: SupportConversation) : NavigationEvent() + data object NavigateToNewTicket : NavigationEvent() data object NavigateBack : NavigationEvent() } @@ -48,7 +49,9 @@ class HESupportViewModel @Inject constructor() : ViewModel() { } fun createNewConversation() { - // Placeholder for creating new conversation - will be implemented when detail screen is ready + viewModelScope.launch { + _navigationEvents.emit(NavigationEvent.NavigateToNewTicket) + } } private fun loadDummyData() { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index c874830b4d38..7fb8dd662397 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5160,4 +5160,19 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Application Logs (Optional) Include application logs Including logs can help our team investigate issues. Logs may contain recent app activity. + + + Contact Support + I need help with + Application + Jetpack Connection + Site Management + Billing & Subscriptions + Technical Issues + Other + Issue Details + Subject + Brief summary of your issue + Site Address (Optional) + https://yoursite.com From d345864e211b40e5bb87864882874289f5126046 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 12:21:51 +0200 Subject: [PATCH 12/81] More screen adjustments --- .../support/he/ui/HENewTicketScreen.kt | 168 ++++++++++++++++-- .../support/he/ui/HESupportActivity.kt | 6 +- .../support/he/ui/HESupportViewModel.kt | 19 +- .../support/main/ui/SupportViewModel.kt | 7 +- .../android/support/model/UserInfo.kt | 7 + WordPress/src/main/res/values/strings.xml | 3 + 6 files changed, 185 insertions(+), 25 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt 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 5b8904d7c2a7..e8fda4371e69 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 @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -53,6 +54,7 @@ import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.compose.theme.neutral +import org.wordpress.android.ui.dataview.compose.RemoteImage enum class SupportCategory(val icon: ImageVector, val labelRes: Int) { APPLICATION(Icons.Default.PhoneAndroid, R.string.he_support_category_application), @@ -67,7 +69,10 @@ enum class SupportCategory(val icon: ImageVector, val labelRes: Int) { @Composable fun HENewTicketScreen( onBackClick: () -> Unit, - onSubmit: (category: SupportCategory, subject: String, siteAddress: String) -> Unit + onSubmit: (category: SupportCategory, subject: String, siteAddress: String) -> Unit, + userName: String = "", + userEmail: String = "", + userAvatarUrl: String? = null ) { var selectedCategory by remember { mutableStateOf(null) } var subject by remember { mutableStateOf("") } @@ -80,6 +85,16 @@ fun HENewTicketScreen( navigationIcon = NavigationIcons.BackIcon, onNavigationIconClick = onBackClick ) + }, + bottomBar = { + SendButton( + enabled = selectedCategory != null && subject.isNotBlank(), + onClick = { + selectedCategory?.let { category -> + onSubmit(category, subject, siteAddress) + } + } + ) } ) { contentPadding -> Column( @@ -162,6 +177,119 @@ 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) + ) + + ContactInformationCard( + userName = userName, + userEmail = userEmail, + userAvatarUrl = userAvatarUrl + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun SendButton( + enabled: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(28.dp) + ) { + Text( + text = stringResource(R.string.he_support_send_ticket_button), + style = MaterialTheme.typography.titleMedium + ) + } + } +} + +@Composable +private fun ContactInformationCard( + userName: String, + userEmail: String, + userAvatarUrl: String? +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp) + ) { + Column { + Text( + text = stringResource(R.string.he_support_contact_email_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center + ) { + 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) + ) + } + } + + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = userName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = userEmail, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } } @@ -192,18 +320,12 @@ private fun CategoryOption( .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier - .size(40.dp), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - } + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) Text( text = label, @@ -231,7 +353,10 @@ private fun HENewTicketScreenPreview() { AppThemeM3(isDarkTheme = false) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> } + onSubmit = { _, _, _ -> }, + userName = "Test user", + userEmail = "test.user@automattic.com", + userAvatarUrl = null ) } } @@ -242,7 +367,10 @@ private fun HENewTicketScreenPreviewDark() { AppThemeM3(isDarkTheme = true) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> } + onSubmit = { _, _, _ -> }, + userName = "Test user", + userEmail = "test.user@automattic.com", + userAvatarUrl = null ) } } @@ -253,7 +381,10 @@ private fun HENewTicketScreenWordPressPreview() { AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> } + onSubmit = { _, _, _ -> }, + userName = "Test user", + userEmail = "test.user@automattic.com", + userAvatarUrl = null ) } } @@ -264,7 +395,10 @@ private fun HENewTicketScreenPreviewWordPressDark() { AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> } + onSubmit = { _, _, _ -> }, + userName = "Test user", + userEmail = "test.user@automattic.com", + userAvatarUrl = null ) } } 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 900f2c6997c3..05ff4afd0f36 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 @@ -106,12 +106,16 @@ class HESupportActivity : AppCompatActivity() { } composable(route = ConversationScreen.NewTicket.name) { + val userInfo by viewModel.userInfo.collectAsState() HENewTicketScreen( onBackClick = { viewModel.onBackFromDetailClick() }, onSubmit = { category, subject, siteAddress -> // TODO: Handle ticket submission viewModel.onBackFromDetailClick() - } + }, + userName = userInfo.userName, + userEmail = userInfo.userEmail, + userAvatarUrl = userInfo.avatarUrl ) } } 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 b25e42f44bb3..99bbd1fde83e 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 @@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.util.generateSampleHESupportConversations +import org.wordpress.android.support.model.UserInfo import javax.inject.Inject @HiltViewModel -class HESupportViewModel @Inject constructor() : ViewModel() { +class HESupportViewModel @Inject constructor( + private val accountStore: AccountStore +) : ViewModel() { sealed class NavigationEvent { data class NavigateToConversationDetail(val conversation: SupportConversation) : NavigationEvent() data object NavigateToNewTicket : NavigationEvent() @@ -28,11 +32,24 @@ class HESupportViewModel @Inject constructor() : ViewModel() { private val _selectedConversation = MutableStateFlow(null) val selectedConversation: StateFlow = _selectedConversation.asStateFlow() + private val _userInfo = MutableStateFlow(UserInfo()) + val userInfo: StateFlow = _userInfo.asStateFlow() + private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() fun init() { loadDummyData() + loadUserInfo() + } + + private fun loadUserInfo() { + val account = accountStore.account + _userInfo.value = UserInfo( + userName = account.displayName.ifEmpty { account.userName }, + userEmail = account.email, + avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } + ) } fun onConversationClick(conversation: SupportConversation) { diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index 61c82e06fa81..4440d6680747 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.support.model.UserInfo import org.wordpress.android.util.AppLog import javax.inject.Inject @@ -26,12 +27,6 @@ class SupportViewModel @Inject constructor( data object NavigateToAskHappinessEngineers : NavigationEvent() } - data class UserInfo( - val userName: String = "", - val userEmail: String = "", - val avatarUrl: String? = null - ) - data class SupportOptionsVisibility( val showAskTheBots: Boolean = true, val showAskHappinessEngineers: Boolean = true diff --git a/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt b/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt new file mode 100644 index 000000000000..ae09527c197b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.support.model + +data class UserInfo( + val userName: String = "", + val userEmail: String = "", + val avatarUrl: String? = null +) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 7fb8dd662397..e10a310ec6e9 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5175,4 +5175,7 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Brief summary of your issue Site Address (Optional) https://yoursite.com + Contact Information + We\'ll email you at this address. + Send From 05773ec58e6eee9a87ad30c59c6c81e38e42ebb6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 12:45:23 +0200 Subject: [PATCH 13/81] Extracting common code --- .../he/ui/HEConversationDetailScreen.kt | 376 +----------------- .../support/he/ui/HENewTicketScreen.kt | 13 +- .../support/he/ui/TicketMainContentView.kt | 170 ++++++++ 3 files changed, 187 insertions(+), 372 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt 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 9826b361d2c6..bdbd6a504062 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 @@ -327,378 +327,12 @@ private fun ReplyBottomSheet( } } - Text( - text = stringResource(R.string.he_support_message_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp) - ) - - OutlinedTextField( - value = messageText, - onValueChange = { messageText = it }, - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - shape = RoundedCornerShape(12.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.he_support_screenshots_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Text( - text = stringResource(R.string.he_support_screenshots_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp) - ) - - Button( - onClick = { /* Placeholder for add screenshots */ }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.CameraAlt, - 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 - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.he_support_app_logs_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.he_support_include_logs_title), - style = MaterialTheme.typography.titleMedium, - 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 - ) - } - - Switch( - checked = includeAppLogs, - onCheckedChange = { includeAppLogs = it } - ) - } - } - } -} - -@Preview(showBackground = true, name = "HE Reply Bottom Sheet Content") -@Composable -private fun ReplyBottomSheetPreview() { - var messageText by remember { mutableStateOf("") } - var includeAppLogs by remember { mutableStateOf(false) } - - AppThemeM3(isDarkTheme = false) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = { }) { - Text( - text = stringResource(R.string.cancel), - style = MaterialTheme.typography.titleMedium - ) - } - - Text( - text = stringResource(R.string.he_support_reply_button), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - TextButton( - onClick = { }, - enabled = messageText.isNotBlank() - ) { - Text( - text = stringResource(R.string.he_support_send_button), - style = MaterialTheme.typography.titleMedium - ) - } - } - - Text( - text = stringResource(R.string.he_support_message_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp) - ) - - OutlinedTextField( - value = messageText, - onValueChange = { messageText = it }, - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - shape = RoundedCornerShape(12.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.he_support_screenshots_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) + TicketMainContentView( + messageText = messageText, + includeAppLogs = includeAppLogs, + onMessageChanged = { message -> messageText = message}, + onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, ) - - Text( - text = stringResource(R.string.he_support_screenshots_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp) - ) - - Button( - onClick = { }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.CameraAlt, - 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 - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.he_support_app_logs_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.he_support_include_logs_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - - Switch( - checked = includeAppLogs, - onCheckedChange = { includeAppLogs = it } - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.he_support_include_logs_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@Preview(showBackground = true, name = "HE Reply Bottom Sheet Content - Dark", uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun ReplyBottomSheetPreviewDark() { - var messageText by remember { mutableStateOf("") } - var includeAppLogs by remember { mutableStateOf(false) } - - AppThemeM3(isDarkTheme = true) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = { }) { - Text( - text = stringResource(R.string.cancel), - style = MaterialTheme.typography.titleMedium - ) - } - - Text( - text = stringResource(R.string.he_support_reply_button), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - TextButton( - onClick = { }, - enabled = messageText.isNotBlank() - ) { - Text( - text = stringResource(R.string.he_support_send_button), - style = MaterialTheme.typography.titleMedium - ) - } - } - - Text( - text = stringResource(R.string.he_support_message_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp) - ) - - OutlinedTextField( - value = messageText, - onValueChange = { messageText = it }, - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - shape = RoundedCornerShape(12.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.he_support_screenshots_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Text( - text = stringResource(R.string.he_support_screenshots_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp) - ) - - Button( - onClick = { }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.CameraAlt, - 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 - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.he_support_app_logs_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.he_support_include_logs_title), - style = MaterialTheme.typography.titleMedium, - 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 - ) - } - - Switch( - checked = includeAppLogs, - onCheckedChange = { includeAppLogs = it } - ) - } } } } 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 e8fda4371e69..a67aa117eec7 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 @@ -77,6 +77,8 @@ fun HENewTicketScreen( var selectedCategory by remember { mutableStateOf(null) } var subject by remember { mutableStateOf("") } var siteAddress by remember { mutableStateOf("") } + var messageText by remember { mutableStateOf("") } + var includeAppLogs by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -178,6 +180,15 @@ fun HENewTicketScreen( Spacer(modifier = Modifier.height(32.dp)) + TicketMainContentView( + messageText = messageText, + includeAppLogs = includeAppLogs, + onMessageChanged = { message -> messageText = message}, + onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, + ) + + Spacer(modifier = Modifier.height(32.dp)) + Text( text = stringResource(R.string.he_support_contact_information), style = MaterialTheme.typography.titleLarge, @@ -317,7 +328,7 @@ private fun CategoryOption( shape = RoundedCornerShape(12.dp) ) .clickable(onClick = onClick) - .padding(16.dp), + .padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( 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 new file mode 100644 index 000000000000..a505dd5b298d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt @@ -0,0 +1,170 @@ +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.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.fillMaxWidth +import androidx.compose.foundation.layout.height +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TicketMainContentView( + messageText: String, + includeAppLogs: Boolean, + onMessageChanged: (String) -> Unit, + onIncludeAppLogsChanged: (Boolean) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = stringResource(R.string.he_support_message_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = messageText, + onValueChange = { message -> onMessageChanged(message) }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_screenshots_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Text( + text = stringResource(R.string.he_support_screenshots_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Button( + onClick = { /* Placeholder for add screenshots */ }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + 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 + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.he_support_app_logs_label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.he_support_include_logs_title), + style = MaterialTheme.typography.titleMedium, + 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 + ) + } + + Switch( + checked = includeAppLogs, + onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) } + ) + } + } +} + +@Preview(showBackground = true, name = "HE main ticket content") +@Composable +private fun ReplyBottomSheetPreview() { + AppThemeM3(isDarkTheme = false) { + TicketMainContentView( + messageText = "", + includeAppLogs = false, + onMessageChanged = { }, + onIncludeAppLogsChanged = { } + ) + } +} + +@Preview(showBackground = true, name = "HE main ticket content - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ReplyBottomSheetPreviewDark() { + AppThemeM3(isDarkTheme = true) { + TicketMainContentView( + messageText = "", + includeAppLogs = false, + onMessageChanged = { }, + onIncludeAppLogsChanged = { } + ) + } +} From b442787bc00f5347263fdc6c4a602a5f41d9d276 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 12:51:22 +0200 Subject: [PATCH 14/81] Margin fix --- .../org/wordpress/android/support/he/ui/HENewTicketScreen.kt | 2 +- .../wordpress/android/support/he/ui/TicketMainContentView.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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 a67aa117eec7..e635f065e2c8 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 @@ -328,7 +328,7 @@ private fun CategoryOption( shape = RoundedCornerShape(12.dp) ) .clickable(onClick = onClick) - .padding(start = 16.dp, end = 16.dp), + .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( 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 a505dd5b298d..c3736a550fa1 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 @@ -46,7 +46,6 @@ fun TicketMainContentView( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) .padding(bottom = 32.dp) ) { Text( From cf4762e10d4a03bad7610202fd8382f73841d020 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 12:56:16 +0200 Subject: [PATCH 15/81] detekt --- .../he/ui/HEConversationDetailScreen.kt | 3 --- .../support/he/ui/HENewTicketScreen.kt | 18 ------------------ .../support/he/ui/HESupportActivity.kt | 3 +-- .../android/support/he/ui/SupportCategory.kt | 19 +++++++++++++++++++ .../support/he/ui/TicketMainContentView.kt | 6 ------ 5 files changed, 20 insertions(+), 29 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/SupportCategory.kt 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 bdbd6a504062..3f9cef7ae016 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 @@ -18,15 +18,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState 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 e635f065e2c8..62e2ece2b0d3 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 @@ -5,7 +5,6 @@ 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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -19,12 +18,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Help -import androidx.compose.material.icons.filled.CreditCard -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -42,7 +35,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -53,18 +45,8 @@ import org.wordpress.android.R import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 -import org.wordpress.android.ui.compose.theme.neutral import org.wordpress.android.ui.dataview.compose.RemoteImage -enum class SupportCategory(val icon: ImageVector, val labelRes: Int) { - APPLICATION(Icons.Default.PhoneAndroid, R.string.he_support_category_application), - JETPACK_CONNECTION(Icons.Default.Settings, R.string.he_support_category_jetpack_connection), - SITE_MANAGEMENT(Icons.Default.Language, R.string.he_support_category_site_management), - BILLING(Icons.Default.CreditCard, R.string.he_support_category_billing), - TECHNICAL_ISSUES(Icons.Default.Settings, R.string.he_support_category_technical_issues), - OTHER(Icons.AutoMirrored.Filled.Help, R.string.he_support_category_other) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun HENewTicketScreen( 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 05ff4afd0f36..cdd9fb3b73f8 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 @@ -110,8 +110,7 @@ class HESupportActivity : AppCompatActivity() { HENewTicketScreen( onBackClick = { viewModel.onBackFromDetailClick() }, onSubmit = { category, subject, siteAddress -> - // TODO: Handle ticket submission - viewModel.onBackFromDetailClick() + // Submit the new ticket }, userName = userInfo.userName, userEmail = userInfo.userEmail, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/SupportCategory.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/SupportCategory.kt new file mode 100644 index 000000000000..6a5240f5f44c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/SupportCategory.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.support.he.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import org.wordpress.android.R + +enum class SupportCategory(val icon: ImageVector, val labelRes: Int) { + APPLICATION(Icons.Default.PhoneAndroid, R.string.he_support_category_application), + JETPACK_CONNECTION(Icons.Default.Settings, R.string.he_support_category_jetpack_connection), + SITE_MANAGEMENT(Icons.Default.Language, R.string.he_support_category_site_management), + BILLING(Icons.Default.CreditCard, R.string.he_support_category_billing), + TECHNICAL_ISSUES(Icons.Default.Settings, R.string.he_support_category_technical_issues), + OTHER(Icons.AutoMirrored.Filled.Help, R.string.he_support_category_other) +} 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 c3736a550fa1..e4c49748a673 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 @@ -20,16 +20,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.wordpress.android.R From 7d318b1c67dfd2f799341a6c842701c4d4d26a65 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 13:09:47 +0200 Subject: [PATCH 16/81] Style --- .../android/support/he/ui/HEConversationDetailScreen.kt | 2 +- .../org/wordpress/android/support/he/ui/HENewTicketScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 3f9cef7ae016..7ffd8c942e9a 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 @@ -327,7 +327,7 @@ private fun ReplyBottomSheet( TicketMainContentView( messageText = messageText, includeAppLogs = includeAppLogs, - onMessageChanged = { message -> messageText = message}, + onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, ) } 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 62e2ece2b0d3..ecd84bf83aa4 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 @@ -165,7 +165,7 @@ fun HENewTicketScreen( TicketMainContentView( messageText = messageText, includeAppLogs = includeAppLogs, - onMessageChanged = { message -> messageText = message}, + onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, ) From d585a4a142bd05cd9639e5f4c44d560088e93023 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 14:18:37 +0200 Subject: [PATCH 17/81] New ticket check --- .../wordpress/android/support/he/ui/HENewTicketScreen.kt | 2 +- .../wordpress/android/support/he/ui/HESupportActivity.kt | 4 ++-- .../wordpress/android/support/he/ui/HESupportViewModel.kt | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) 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 ecd84bf83aa4..49edfb481052 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 @@ -72,7 +72,7 @@ fun HENewTicketScreen( }, bottomBar = { SendButton( - enabled = selectedCategory != null && subject.isNotBlank(), + enabled = selectedCategory != null && subject.isNotBlank() && messageText.isNotBlank(), onClick = { selectedCategory?.let { category -> onSubmit(category, subject, siteAddress) 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 cdd9fb3b73f8..ac9a6eab422d 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 @@ -90,7 +90,7 @@ class HESupportActivity : AppCompatActivity() { }, onBackClick = { finish() }, onCreateNewConversationClick = { - viewModel.createNewConversation() + viewModel.onCreateNewConversation() } ) } @@ -110,7 +110,7 @@ class HESupportActivity : AppCompatActivity() { HENewTicketScreen( onBackClick = { viewModel.onBackFromDetailClick() }, onSubmit = { category, subject, siteAddress -> - // Submit the new ticket + viewModel.onSendNewConversation() }, userName = userInfo.userName, userEmail = userInfo.userEmail, 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 99bbd1fde83e..cff0adf8e3cb 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 @@ -65,12 +65,18 @@ class HESupportViewModel @Inject constructor( } } - fun createNewConversation() { + fun onCreateNewConversation() { viewModelScope.launch { _navigationEvents.emit(NavigationEvent.NavigateToNewTicket) } } + fun onSendNewConversation() { + viewModelScope.launch { + _navigationEvents.emit(NavigationEvent.NavigateBack) + } + } + private fun loadDummyData() { _conversations.value = generateSampleHESupportConversations() } From 8c651fc84211e906ade3808e4ce9b05460ec18a4 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 20 Oct 2025 14:21:19 +0200 Subject: [PATCH 18/81] Creating tests --- .../support/he/ui/HESupportViewModelTest.kt | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt 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 new file mode 100644 index 000000000000..ce48b4000f44 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt @@ -0,0 +1,284 @@ +package org.wordpress.android.support.he.ui + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.AccountModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.model.SupportMessage +import java.util.Date + +@ExperimentalCoroutinesApi +class HESupportViewModelTest : BaseUnitTest() { + @Mock + lateinit var accountStore: AccountStore + + @Mock + lateinit var account: AccountModel + + private lateinit var viewModel: HESupportViewModel + + @Before + fun setUp() { + viewModel = HESupportViewModel( + accountStore = accountStore + ) + } + + // region init() tests + + @Test + fun `init loads user info when account exists`() { + // Given + val displayName = "Test User" + val email = "test@example.com" + val avatarUrl = "https://example.com/avatar.jpg" + + whenever(accountStore.account).thenReturn(account) + whenever(account.displayName).thenReturn(displayName) + whenever(account.email).thenReturn(email) + whenever(account.avatarUrl).thenReturn(avatarUrl) + + // When + viewModel.init() + + // Then + assertThat(viewModel.userInfo.value.userName).isEqualTo(displayName) + assertThat(viewModel.userInfo.value.userEmail).isEqualTo(email) + assertThat(viewModel.userInfo.value.avatarUrl).isEqualTo(avatarUrl) + } + + @Test + fun `init uses userName when displayName is empty`() { + // Given + val userName = "testuser" + val email = "test@example.com" + + whenever(accountStore.account).thenReturn(account) + whenever(account.displayName).thenReturn("") + whenever(account.userName).thenReturn(userName) + whenever(account.email).thenReturn(email) + whenever(account.avatarUrl).thenReturn("") + + // When + viewModel.init() + + // Then + assertThat(viewModel.userInfo.value.userName).isEqualTo(userName) + } + + @Test + fun `init sets avatarUrl to null when empty`() { + // Given + whenever(accountStore.account).thenReturn(account) + whenever(account.displayName).thenReturn("Test User") + whenever(account.email).thenReturn("test@example.com") + whenever(account.avatarUrl).thenReturn("") + + // When + viewModel.init() + + // Then + assertThat(viewModel.userInfo.value.avatarUrl).isNull() + } + + // endregion + + // region onConversationClick() tests + + @Test + fun `onConversationClick updates selected conversation`() { + // Given + val conversation = createTestConversation() + + // When + viewModel.onConversationClick(conversation) + + // Then + assertThat(viewModel.selectedConversation.value).isEqualTo(conversation) + } + + @Test + fun `onConversationClick emits NavigateToConversationDetail event`() = test { + // Given + val conversation = createTestConversation() + + // When + viewModel.navigationEvents.test { + viewModel.onConversationClick(conversation) + + // Then + val event = awaitItem() + assertThat(event).isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) + val navigateEvent = event as HESupportViewModel.NavigationEvent.NavigateToConversationDetail + assertThat(navigateEvent.conversation).isEqualTo(conversation) + } + } + + // endregion + + // region onBackFromDetailClick() tests + + @Test + fun `onBackFromDetailClick emits NavigateBack event`() = test { + // When + viewModel.navigationEvents.test { + viewModel.onBackFromDetailClick() + + // Then + val event = awaitItem() + assertThat(event).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) + } + } + + // endregion + + // region onCreateNewConversation() tests + + @Test + fun `onCreateNewConversation emits NavigateToNewTicket event`() = test { + // When + viewModel.navigationEvents.test { + viewModel.onCreateNewConversation() + + // Then + val event = awaitItem() + assertThat(event).isEqualTo(HESupportViewModel.NavigationEvent.NavigateToNewTicket) + } + } + + // endregion + + // region onSendNewConversation() tests + + @Test + fun `onSendNewConversation emits NavigateBack event`() = test { + // When + viewModel.navigationEvents.test { + viewModel.onSendNewConversation() + + // Then + val event = awaitItem() + assertThat(event).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) + } + } + + // endregion + + // region StateFlow initial values tests + + @Test + fun `conversations is empty before init`() { + // Then + assertThat(viewModel.conversations.value).isEmpty() + } + + @Test + fun `selectedConversation is null before init`() { + // Then + assertThat(viewModel.selectedConversation.value).isNull() + } + + @Test + fun `userInfo has correct initial values before init`() { + // Then + assertThat(viewModel.userInfo.value.userName).isEmpty() + assertThat(viewModel.userInfo.value.userEmail).isEmpty() + assertThat(viewModel.userInfo.value.avatarUrl).isNull() + } + + // endregion + + // region Navigation event sequence tests + + @Test + fun `can navigate to detail and back in sequence`() = test { + // Given + val conversation = createTestConversation() + + // When + viewModel.navigationEvents.test { + viewModel.onConversationClick(conversation) + val firstEvent = awaitItem() + + viewModel.onBackFromDetailClick() + val secondEvent = awaitItem() + + // Then + assertThat(firstEvent) + .isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) + assertThat(secondEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) + } + } + + @Test + fun `can create new ticket and send in sequence`() = test { + // When + viewModel.navigationEvents.test { + viewModel.onCreateNewConversation() + val firstEvent = awaitItem() + + viewModel.onSendNewConversation() + val secondEvent = awaitItem() + + // Then + assertThat(firstEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateToNewTicket) + assertThat(secondEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) + } + } + + // endregion + + // region Multiple conversation selection tests + + @Test + fun `selecting different conversations updates selectedConversation`() { + // Given + val conversation1 = createTestConversation(id = 1L, title = "First") + val conversation2 = createTestConversation(id = 2L, title = "Second") + + // When + viewModel.onConversationClick(conversation1) + val firstSelection = viewModel.selectedConversation.value + + viewModel.onConversationClick(conversation2) + val secondSelection = viewModel.selectedConversation.value + + // Then + assertThat(firstSelection).isEqualTo(conversation1) + assertThat(secondSelection).isEqualTo(conversation2) + assertThat(secondSelection).isNotEqualTo(firstSelection) + } + + // endregion + + // Helper methods + + private fun createTestConversation( + id: Long = 1L, + title: String = "Test Conversation", + description: String = "Test Description" + ): SupportConversation { + return SupportConversation( + id = id, + title = title, + description = description, + lastMessageSentAt = Date(System.currentTimeMillis()), + messages = listOf( + SupportMessage( + id = 1L, + text = "Test message", + createdAt = Date(System.currentTimeMillis()), + authorName = "Test Author", + authorIsUser = true + ) + ) + ) + } +} From 3515fd0bda4950e7f08abf4696214ad7921c57f2 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 11:54:32 +0200 Subject: [PATCH 19/81] Creating repository and load conversations function --- .../restapi/WpComApiClientProvider.kt | 29 ++++++++ .../he/repository/HESupportRepository.kt | 69 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/networking/restapi/WpComApiClientProvider.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt diff --git a/WordPress/src/main/java/org/wordpress/android/networking/restapi/WpComApiClientProvider.kt b/WordPress/src/main/java/org/wordpress/android/networking/restapi/WpComApiClientProvider.kt new file mode 100644 index 000000000000..a6cdc63bd162 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/networking/restapi/WpComApiClientProvider.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.networking.restapi + +import okhttp3.OkHttpClient +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpHttpClient +import rs.wordpress.api.kotlin.WpRequestExecutor +import uniffi.wp_api.WpAuthentication +import uniffi.wp_api.WpAuthenticationProvider +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +private const val READ_WRITE_TIMEOUT = 60L +private const val CONNECT_TIMEOUT = 30L + +class WpComApiClientProvider @Inject constructor() { + fun getWpComApiClient(accessToken: String): WpComApiClient { + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(READ_WRITE_TIMEOUT, TimeUnit.SECONDS) + .writeTimeout(READ_WRITE_TIMEOUT, TimeUnit.SECONDS) + .build() + + return WpComApiClient( + requestExecutor = WpRequestExecutor(httpClient = WpHttpClient.CustomOkHttpClient(okHttpClient)), + authProvider = WpAuthenticationProvider.staticWithAuth(WpAuthentication.Bearer(token = accessToken!!) + ) + ) + } +} 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 new file mode 100644 index 000000000000..13fa2d60f106 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/repository/HESupportRepository.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.support.he.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.utils.AppLogWrapper +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.support.he.model.SupportConversation +import org.wordpress.android.util.AppLog +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.AddMessageToBotConversationParams +import uniffi.wp_api.BotConversationSummary +import uniffi.wp_api.CreateBotConversationParams +import uniffi.wp_api.CreateSupportTicketParams +import uniffi.wp_api.GetBotConversationParams +import uniffi.wp_api.SupportConversationSummary +import javax.inject.Inject +import javax.inject.Named + +private const val APPLICATION_ID = "jetpack" + +class HESupportRepository @Inject constructor( + private val appLogWrapper: AppLogWrapper, + private val wpComApiClientProvider: WpComApiClientProvider, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, +) { + private var accessToken: String? = null + + private val wpComApiClient: WpComApiClient by lazy { + check(accessToken != null) { "Repository not initialized" } + wpComApiClientProvider.getWpComApiClient(accessToken!!) + } + + fun init(accessToken: String) { + this.accessToken = accessToken + } + + suspend fun loadConversations(subject: String, message: String, ): List = withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportTickets().getSupportConversationList() + } + + when (response) { + is WpRequestResult.Success -> { + val conversations = response.response.data + conversations.toSupportConversations() + } + + else -> { + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading support conversations: $response") + emptyList() + } + } + } + + private fun List.toSupportConversations(): List = + map { + SupportConversation( + id = it.id.toLong(), + title = it.title, + description = it.description, + lastMessageSentAt = it.updatedAt, + messages = emptyList() + ) + } +} From 3d999196edbe2e6e55c93dbb1360e3edbd8f1cf3 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 12:04:29 +0200 Subject: [PATCH 20/81] Adding createConversation function --- .../he/repository/HESupportRepository.kt | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) 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 13fa2d60f106..12e1ea2f4422 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 @@ -8,6 +8,7 @@ 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.support.he.model.SupportConversation +import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.util.AppLog import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult @@ -17,8 +18,11 @@ import uniffi.wp_api.CreateBotConversationParams import uniffi.wp_api.CreateSupportTicketParams import uniffi.wp_api.GetBotConversationParams import uniffi.wp_api.SupportConversationSummary +import uniffi.wp_api.SupportMessageAuthor +import java.util.Date import javax.inject.Inject import javax.inject.Named +import kotlin.String private const val APPLICATION_ID = "jetpack" @@ -56,6 +60,30 @@ class HESupportRepository @Inject constructor( } } + suspend fun createConversation(subject: String, message: String, ): SupportConversation? = withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportTickets().createSupportTicket( + CreateSupportTicketParams( + subject = subject, + message = message, + application = APPLICATION_ID, // Only jetpack is supported + ) + ) + } + + when (response) { + is WpRequestResult.Success -> { + val conversations = response.response.data + conversations.toSupportConversation() + } + + else -> { + appLogWrapper.e(AppLog.T.SUPPORT, "Error crreating support conversations: $response") + null + } + } + } + private fun List.toSupportConversations(): List = map { SupportConversation( @@ -66,4 +94,25 @@ class HESupportRepository @Inject constructor( messages = emptyList() ) } + + private fun uniffi.wp_api.SupportConversation.toSupportConversation(): SupportConversation = + SupportConversation( + id = this.id.toLong(), + title = this.title, + description = this.description, + lastMessageSentAt = this.updatedAt, + messages = this.messages.map { it.toSupportMessage() } + ) + + private fun uniffi.wp_api.SupportMessage.toSupportMessage(): SupportMessage = + SupportMessage( + id = this.id.toLong(), + text = this.content, + createdAt = this.createdAt, + authorName = when (this.author) { + is SupportMessageAuthor.User -> (this.author as SupportMessageAuthor.User).v1.displayName + is SupportMessageAuthor.SupportAgent -> (this.author as SupportMessageAuthor.SupportAgent).v1.name + }, + authorIsUser = this.author is SupportMessageAuthor.User + ) } From 0be28b4594d98a30f672d5cdc2d438b7ce103af6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 12:16:52 +0200 Subject: [PATCH 21/81] Creating loadConversation func --- .../he/repository/HESupportRepository.kt | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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 12e1ea2f4422..2141e87fbbf5 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 @@ -42,7 +42,7 @@ class HESupportRepository @Inject constructor( this.accessToken = accessToken } - suspend fun loadConversations(subject: String, message: String, ): List = withContext(ioDispatcher) { + suspend fun loadConversations(subject: String, message: String): List = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportTickets().getSupportConversationList() } @@ -60,6 +60,26 @@ class HESupportRepository @Inject constructor( } } + suspend fun loadConversation(conversationId: Long): SupportConversation? = withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportTickets().getSupportConversation( + conversationId = conversationId.toULong() + ) + } + + when (response) { + is WpRequestResult.Success -> { + val conversation = response.response.data + conversation.toSupportConversation() + } + + else -> { + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading support conversation: $response") + null + } + } + } + suspend fun createConversation(subject: String, message: String, ): SupportConversation? = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportTickets().createSupportTicket( @@ -73,12 +93,12 @@ class HESupportRepository @Inject constructor( when (response) { is WpRequestResult.Success -> { - val conversations = response.response.data - conversations.toSupportConversation() + val conversation = response.response.data + conversation.toSupportConversation() } else -> { - appLogWrapper.e(AppLog.T.SUPPORT, "Error crreating support conversations: $response") + appLogWrapper.e(AppLog.T.SUPPORT, "Error creating support conversations: $response") null } } From 40a5880a75ba6d3f5bdb0cd0cfc33f68c525f349 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 12:30:20 +0200 Subject: [PATCH 22/81] Loading conversations form the viewmodel --- .../he/repository/HESupportRepository.kt | 9 +---- .../support/he/ui/HESupportActivity.kt | 24 +++++++++++- .../support/he/ui/HESupportViewModel.kt | 38 +++++++++++++++++-- WordPress/src/main/res/values/strings.xml | 1 + 4 files changed, 58 insertions(+), 14 deletions(-) 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 2141e87fbbf5..1ebad534f36a 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 @@ -5,21 +5,14 @@ import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.utils.AppLogWrapper 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.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.util.AppLog import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult -import uniffi.wp_api.AddMessageToBotConversationParams -import uniffi.wp_api.BotConversationSummary -import uniffi.wp_api.CreateBotConversationParams import uniffi.wp_api.CreateSupportTicketParams -import uniffi.wp_api.GetBotConversationParams import uniffi.wp_api.SupportConversationSummary import uniffi.wp_api.SupportMessageAuthor -import java.util.Date import javax.inject.Inject import javax.inject.Named import kotlin.String @@ -42,7 +35,7 @@ class HESupportRepository @Inject constructor( this.accessToken = accessToken } - suspend fun loadConversations(subject: String, message: String): List = withContext(ioDispatcher) { + suspend fun loadConversations(): List = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportTickets().getSupportConversationList() } 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 ac9a6eab422d..559ba8221fe5 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 @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.view.Gravity import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable @@ -21,6 +22,8 @@ import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.util.ToastUtils +import org.wordpress.android.R @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { @@ -31,8 +34,6 @@ class HESupportActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.init() - observeNavigationEvents() composeView = ComposeView(this) setContentView( composeView.apply { @@ -45,6 +46,25 @@ class HESupportActivity : AppCompatActivity() { } } ) + observeNavigationEvents() + observeErrorEvents() + viewModel.init() + } + + private fun observeErrorEvents() { + // Observe error messages and show them as Toast + lifecycleScope.launch { + viewModel.errorMessage.collect { errorType -> + val errorMessage = when (errorType) { + HESupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) + null -> null + } + errorMessage?.let { + ToastUtils.showToast(this@HESupportActivity, it, ToastUtils.Duration.LONG, Gravity.CENTER) + viewModel.clearError() + } + } + } } private fun observeNavigationEvents() { 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 cff0adf8e3cb..78ce7d8cf9d3 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 @@ -11,14 +11,19 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.support.he.util.generateSampleHESupportConversations import org.wordpress.android.support.model.UserInfo +import org.wordpress.android.util.AppLog import javax.inject.Inject @HiltViewModel class HESupportViewModel @Inject constructor( - private val accountStore: AccountStore + private val accountStore: AccountStore, + private val heSupportRepository: HESupportRepository, + private val appLogWrapper: AppLogWrapper, ) : ViewModel() { sealed class NavigationEvent { data class NavigateToConversationDetail(val conversation: SupportConversation) : NavigationEvent() @@ -38,9 +43,15 @@ class HESupportViewModel @Inject constructor( private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + private val _isLoadingConversations = MutableStateFlow(false) + val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + fun init() { - loadDummyData() loadUserInfo() + loadConversations() } private fun loadUserInfo() { @@ -52,6 +63,23 @@ class HESupportViewModel @Inject constructor( ) } + private fun loadConversations() { + viewModelScope.launch { + try { + _isLoadingConversations.value = true + val conversations = heSupportRepository.loadConversations() + _conversations.value = conversations + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e( + AppLog.T.SUPPORT, "Error loading HE conversations: " + + "${throwable.message} - ${throwable.stackTraceToString()}" + ) + } + _isLoadingConversations.value = false + } + } + fun onConversationClick(conversation: SupportConversation) { viewModelScope.launch { _selectedConversation.value = conversation @@ -77,7 +105,9 @@ class HESupportViewModel @Inject constructor( } } - private fun loadDummyData() { - _conversations.value = generateSampleHESupportConversations() + fun clearError() { + _errorMessage.value = null } + + enum class ErrorType { GENERAL } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index e10a310ec6e9..ff707a6f45be 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5178,4 +5178,5 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Contact Information We\'ll email you at this address. Send + Something wrong happened. Please try again later. From a55994eb0023a5ac3082c808af8c6bb81595e9af Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 12:38:24 +0200 Subject: [PATCH 23/81] Adding loading spinner --- .../he/ui/HEConversationsListScreen.kt | 60 +++++++++++++------ .../support/he/ui/HESupportActivity.kt | 1 + 2 files changed, 43 insertions(+), 18 deletions(-) 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 0e26c2c46a55..270c487e71b6 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 @@ -3,6 +3,7 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,6 +17,7 @@ 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.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -48,6 +50,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @Composable fun HEConversationsListScreen( conversations: StateFlow>, + isLoadingConversations: StateFlow, onConversationClick: (SupportConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit @@ -74,6 +77,7 @@ fun HEConversationsListScreen( ShowConversationsList( modifier = Modifier.padding(contentPadding), conversations = conversations, + isLoadingConversations = isLoadingConversations, onConversationClick = onConversationClick ) } @@ -83,32 +87,44 @@ fun HEConversationsListScreen( private fun ShowConversationsList( modifier: Modifier, conversations: StateFlow>, + isLoadingConversations: StateFlow, onConversationClick: (SupportConversation) -> Unit ) { val conversationsList by conversations.collectAsState() + val isLoading by isLoadingConversations.collectAsState() - LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp) + Box( + modifier = modifier.fillMaxSize() ) { - item { - Spacer(modifier = Modifier.height(16.dp)) - } + 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, - onClick = { onConversationClick(conversation) } - ) - Spacer(modifier = Modifier.height(12.dp)) + items( + items = conversationsList, + key = { it.id } + ) { conversation -> + ConversationCard( + conversation = conversation, + onClick = { onConversationClick(conversation) } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } } - item { - Spacer(modifier = Modifier.height(16.dp)) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) } } } @@ -182,10 +198,12 @@ private fun ConversationCard( @Composable private fun ConversationsScreenPreview() { val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) + val isLoading = MutableStateFlow(false) AppThemeM3(isDarkTheme = false) { HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { } @@ -197,10 +215,12 @@ private fun ConversationsScreenPreview() { @Composable private fun ConversationsScreenPreviewDark() { val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) + val isLoading = MutableStateFlow(false) AppThemeM3(isDarkTheme = true) { HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { } @@ -212,10 +232,12 @@ private fun ConversationsScreenPreviewDark() { @Composable private fun ConversationsScreenWordPressPreview() { val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) + val isLoading = MutableStateFlow(false) AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { } @@ -227,10 +249,12 @@ private fun ConversationsScreenWordPressPreview() { @Composable private fun ConversationsScreenPreviewWordPressDark() { val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) + val isLoading = MutableStateFlow(false) AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { } 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 559ba8221fe5..68b7f5c11e66 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 @@ -105,6 +105,7 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.List.name) { HEConversationsListScreen( conversations = viewModel.conversations, + isLoadingConversations = viewModel.isLoadingConversations, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, From c82458c8650d83f7d91ebb4ffa59048e88aa0986 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 12:43:29 +0200 Subject: [PATCH 24/81] Pull to refresh --- .../he/ui/HEConversationsListScreen.kt | 35 ++++++++++--------- .../support/he/ui/HESupportActivity.kt | 3 ++ .../support/he/ui/HESupportViewModel.kt | 4 +++ 3 files changed, 26 insertions(+), 16 deletions(-) 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 270c487e71b6..85684d03678c 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 @@ -3,7 +3,6 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,13 +16,13 @@ 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.CircularProgressIndicator 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.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -53,7 +52,8 @@ fun HEConversationsListScreen( isLoadingConversations: StateFlow, onConversationClick: (SupportConversation) -> Unit, onBackClick: () -> Unit, - onCreateNewConversationClick: () -> Unit + onCreateNewConversationClick: () -> Unit, + onRefresh: () -> Unit ) { Scaffold( topBar = { @@ -78,22 +78,27 @@ fun HEConversationsListScreen( modifier = Modifier.padding(contentPadding), conversations = conversations, isLoadingConversations = isLoadingConversations, - onConversationClick = onConversationClick + onConversationClick = onConversationClick, + onRefresh = onRefresh ) } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShowConversationsList( modifier: Modifier, conversations: StateFlow>, isLoadingConversations: StateFlow, - onConversationClick: (SupportConversation) -> Unit + onConversationClick: (SupportConversation) -> Unit, + onRefresh: () -> Unit ) { val conversationsList by conversations.collectAsState() val isLoading by isLoadingConversations.collectAsState() - Box( + PullToRefreshBox( + isRefreshing = isLoading, + onRefresh = onRefresh, modifier = modifier.fillMaxSize() ) { LazyColumn( @@ -120,12 +125,6 @@ private fun ShowConversationsList( Spacer(modifier = Modifier.height(16.dp)) } } - - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) - } } } @@ -206,7 +205,8 @@ private fun ConversationsScreenPreview() { isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, - onCreateNewConversationClick = { } + onCreateNewConversationClick = { }, + onRefresh = { } ) } } @@ -223,7 +223,8 @@ private fun ConversationsScreenPreviewDark() { isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, - onCreateNewConversationClick = { } + onCreateNewConversationClick = { }, + onRefresh = { } ) } } @@ -240,7 +241,8 @@ private fun ConversationsScreenWordPressPreview() { isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, - onCreateNewConversationClick = { } + onCreateNewConversationClick = { }, + onRefresh = { } ) } } @@ -257,7 +259,8 @@ private fun ConversationsScreenPreviewWordPressDark() { isLoadingConversations = isLoading.asStateFlow(), onConversationClick = { }, onBackClick = { }, - onCreateNewConversationClick = { } + onCreateNewConversationClick = { }, + onRefresh = { } ) } } 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 68b7f5c11e66..9176edf4d44a 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 @@ -112,6 +112,9 @@ class HESupportActivity : AppCompatActivity() { onBackClick = { finish() }, onCreateNewConversationClick = { viewModel.onCreateNewConversation() + }, + onRefresh = { + viewModel.refreshConversations() } ) } 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 78ce7d8cf9d3..7f28ed86ea97 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 @@ -80,6 +80,10 @@ class HESupportViewModel @Inject constructor( } } + fun refreshConversations() { + loadConversations() + } + fun onConversationClick(conversation: SupportConversation) { viewModelScope.launch { _selectedConversation.value = conversation From 73a434abeafa8a04a1e662a29788437384ecd6dc Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 12:58:19 +0200 Subject: [PATCH 25/81] Proper ionitialization --- .../support/he/ui/HESupportActivity.kt | 1 + .../support/he/ui/HESupportViewModel.kt | 24 +++++++++++++------ .../android/support/model/UserInfo.kt | 1 + WordPress/src/main/res/values/strings.xml | 1 + 4 files changed, 20 insertions(+), 7 deletions(-) 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 9176edf4d44a..64c656643bf1 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 @@ -58,6 +58,7 @@ class HESupportActivity : AppCompatActivity() { val errorMessage = when (errorType) { HESupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) null -> null + HESupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) } errorMessage?.let { ToastUtils.showToast(this@HESupportActivity, it, ToastUtils.Duration.LONG, Gravity.CENTER) 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 7f28ed86ea97..588839504b3b 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 @@ -55,12 +55,22 @@ class HESupportViewModel @Inject constructor( } private fun loadUserInfo() { - val account = accountStore.account - _userInfo.value = UserInfo( - userName = account.displayName.ifEmpty { account.userName }, - userEmail = account.email, - avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } - ) + viewModelScope.launch { + if (!accountStore.hasAccessToken()) { + _errorMessage.value = ErrorType.FORBIDDEN + _navigationEvents.emit(NavigationEvent.NavigateBack) + return@launch + } + val accessToken = accountStore.accessToken!! + val account = accountStore.account + heSupportRepository.init(accessToken) + _userInfo.value = UserInfo( + accessToken = accessToken, + userName = account.displayName.ifEmpty { account.userName }, + userEmail = account.email, + avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } + ) + } } private fun loadConversations() { @@ -113,5 +123,5 @@ class HESupportViewModel @Inject constructor( _errorMessage.value = null } - enum class ErrorType { GENERAL } + enum class ErrorType { GENERAL, FORBIDDEN } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt b/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt index ae09527c197b..1f82b4628595 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt @@ -1,6 +1,7 @@ package org.wordpress.android.support.model data class UserInfo( + val accessToken: String = "", val userName: String = "", val userEmail: String = "", val avatarUrl: String? = null diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index ff707a6f45be..3c88e9a56034 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5179,4 +5179,5 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> We\'ll email you at this address. Send Something wrong happened. Please try again later. + Sorry, you are not allowed to perform the action. From 087d07a78bbdd3da6873a76746a031a628b90291 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 13:03:18 +0200 Subject: [PATCH 26/81] Adding empty screen --- .../he/ui/HEConversationsListScreen.kt | 98 ++++++++++++++----- WordPress/src/main/res/values/strings.xml | 3 + 2 files changed, 79 insertions(+), 22 deletions(-) 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 85684d03678c..be40412060c6 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 @@ -16,6 +16,7 @@ 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.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -23,6 +24,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.ui.text.style.TextAlign import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -79,7 +81,8 @@ fun HEConversationsListScreen( conversations = conversations, isLoadingConversations = isLoadingConversations, onConversationClick = onConversationClick, - onRefresh = onRefresh + onRefresh = onRefresh, + onCreateNewConversationClick = onCreateNewConversationClick ) } } @@ -91,7 +94,8 @@ private fun ShowConversationsList( conversations: StateFlow>, isLoadingConversations: StateFlow, onConversationClick: (SupportConversation) -> Unit, - onRefresh: () -> Unit + onRefresh: () -> Unit, + onCreateNewConversationClick: () -> Unit ) { val conversationsList by conversations.collectAsState() val isLoading by isLoadingConversations.collectAsState() @@ -101,28 +105,35 @@ private fun ShowConversationsList( onRefresh = onRefresh, modifier = modifier.fillMaxSize() ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - item { - Spacer(modifier = Modifier.height(16.dp)) - } + if (conversationsList.isEmpty() && !isLoading) { + EmptyConversationsView( + modifier = Modifier, + onCreateNewConversationClick = onCreateNewConversationClick + ) + } 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, - onClick = { onConversationClick(conversation) } - ) - Spacer(modifier = Modifier.height(12.dp)) - } + items( + items = conversationsList, + key = { it.id } + ) { conversation -> + ConversationCard( + conversation = conversation, + onClick = { onConversationClick(conversation) } + ) + Spacer(modifier = Modifier.height(12.dp)) + } - item { - Spacer(modifier = Modifier.height(16.dp)) + item { + Spacer(modifier = Modifier.height(16.dp)) + } } } } @@ -193,6 +204,49 @@ private fun ConversationCard( } } +@Composable +private fun EmptyConversationsView( + modifier: Modifier, + onCreateNewConversationClick: () -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "💬", + style = MaterialTheme.typography.displayLarge + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(R.string.he_support_empty_conversations_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.padding(8.dp)) + + Text( + text = stringResource(R.string.he_support_empty_conversations_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.padding(24.dp)) + + Button(onClick = onCreateNewConversationClick) { + Text(text = stringResource(R.string.he_support_empty_conversations_button)) + } + } +} + @Preview(showBackground = true, name = "HE Support Conversations List") @Composable private fun ConversationsScreenPreview() { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 3c88e9a56034..77b75cc26e35 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5147,6 +5147,9 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Support Conversations New conversation + No conversations yet + Start a conversation with our support team to get help with your questions. + Start Conversation %1$d Messages From 3b5a1e5d63db6994e042a67b555535bd41c3d444 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 14:40:19 +0200 Subject: [PATCH 27/81] Handling send new conversation --- .../he/repository/HESupportRepository.kt | 9 ++++- .../support/he/ui/HESupportViewModel.kt | 37 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) 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 1ebad534f36a..704f9b4db591 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 @@ -73,12 +73,19 @@ class HESupportRepository @Inject constructor( } } - suspend fun createConversation(subject: String, message: String, ): SupportConversation? = withContext(ioDispatcher) { + suspend fun createConversation( + subject: String, + message: String, + tags: List, + attachments: List + ): SupportConversation? = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportTickets().createSupportTicket( CreateSupportTicketParams( subject = subject, message = message, + tags = tags, + attachments = attachments, application = APPLICATION_ID, // Only jetpack is supported ) ) 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 588839504b3b..86250d07ed48 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 @@ -18,6 +18,7 @@ import org.wordpress.android.support.he.util.generateSampleHESupportConversation import org.wordpress.android.support.model.UserInfo import org.wordpress.android.util.AppLog import javax.inject.Inject +import kotlin.String @HiltViewModel class HESupportViewModel @Inject constructor( @@ -46,6 +47,9 @@ class HESupportViewModel @Inject constructor( private val _isLoadingConversations = MutableStateFlow(false) val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() + private val _isLoadingSend = MutableStateFlow(false) + val isLoadingSend: StateFlow = _isLoadingSend.asStateFlow() + private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() @@ -113,9 +117,38 @@ class HESupportViewModel @Inject constructor( } } - fun onSendNewConversation() { + fun onSendNewConversation( + subject: String, + message: String, + tags: List, + attachments: List + ) { viewModelScope.launch { - _navigationEvents.emit(NavigationEvent.NavigateBack) + try { + _isLoadingSend.value = true + val conversation = heSupportRepository.createConversation( + subject = subject, + message = message, + tags = tags, + attachments = attachments + ) + if (conversation == null) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e( + AppLog.T.SUPPORT, "Error creating HE conversation: result null" + ) + } else { + _selectedConversation.value = conversation + _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail(conversation)) + } + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e( + AppLog.T.SUPPORT, "Error creating HE conversation: " + + "${throwable.message} - ${throwable.stackTraceToString()}" + ) + } + _isLoadingSend.value = false } } From 4febc3d8b3dc42f00eff307cbe5c4237bbe4bd91 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 14:45:36 +0200 Subject: [PATCH 28/81] Show loading when sending --- .../support/he/ui/HENewTicketScreen.kt | 24 ++++++++++++++----- .../support/he/ui/HESupportActivity.kt | 4 +++- .../support/he/ui/HESupportViewModel.kt | 9 ++++--- 3 files changed, 25 insertions(+), 12 deletions(-) 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 49edfb481052..82d2468e7933 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 @@ -19,6 +19,7 @@ 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.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -54,7 +55,8 @@ fun HENewTicketScreen( onSubmit: (category: SupportCategory, subject: String, siteAddress: String) -> Unit, userName: String = "", userEmail: String = "", - userAvatarUrl: String? = null + userAvatarUrl: String? = null, + isSendingNewConversation: Boolean = false ) { var selectedCategory by remember { mutableStateOf(null) } var subject by remember { mutableStateOf("") } @@ -73,6 +75,7 @@ fun HENewTicketScreen( bottomBar = { SendButton( enabled = selectedCategory != null && subject.isNotBlank() && messageText.isNotBlank(), + isLoading = isSendingNewConversation, onClick = { selectedCategory?.let { category -> onSubmit(category, subject, siteAddress) @@ -192,6 +195,7 @@ fun HENewTicketScreen( @Composable private fun SendButton( enabled: Boolean, + isLoading: Boolean, onClick: () -> Unit ) { Box( @@ -201,16 +205,24 @@ private fun SendButton( ) { Button( onClick = onClick, - enabled = enabled, + enabled = enabled && !isLoading, modifier = Modifier .fillMaxWidth() .height(56.dp), shape = RoundedCornerShape(28.dp) ) { - Text( - text = stringResource(R.string.he_support_send_ticket_button), - style = MaterialTheme.typography.titleMedium - ) + 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 + ) + } } } } 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 64c656643bf1..81b24e0cdedf 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 @@ -132,6 +132,7 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.NewTicket.name) { val userInfo by viewModel.userInfo.collectAsState() + val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() HENewTicketScreen( onBackClick = { viewModel.onBackFromDetailClick() }, onSubmit = { category, subject, siteAddress -> @@ -139,7 +140,8 @@ class HESupportActivity : AppCompatActivity() { }, userName = userInfo.userName, userEmail = userInfo.userEmail, - userAvatarUrl = userInfo.avatarUrl + userAvatarUrl = userInfo.avatarUrl, + isSendingNewConversation = isSendingNewConversation ) } } 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 86250d07ed48..30a91b775105 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 @@ -14,7 +14,6 @@ import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.HESupportRepository -import org.wordpress.android.support.he.util.generateSampleHESupportConversations import org.wordpress.android.support.model.UserInfo import org.wordpress.android.util.AppLog import javax.inject.Inject @@ -47,8 +46,8 @@ class HESupportViewModel @Inject constructor( private val _isLoadingConversations = MutableStateFlow(false) val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() - private val _isLoadingSend = MutableStateFlow(false) - val isLoadingSend: StateFlow = _isLoadingSend.asStateFlow() + private val _isSendingNewConversation = MutableStateFlow(false) + val isSendingNewConversation: StateFlow = _isSendingNewConversation.asStateFlow() private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() @@ -125,7 +124,7 @@ class HESupportViewModel @Inject constructor( ) { viewModelScope.launch { try { - _isLoadingSend.value = true + _isSendingNewConversation.value = true val conversation = heSupportRepository.createConversation( subject = subject, message = message, @@ -148,7 +147,7 @@ class HESupportViewModel @Inject constructor( "${throwable.message} - ${throwable.stackTraceToString()}" ) } - _isLoadingSend.value = false + _isSendingNewConversation.value = false } } From e1215a9bc3448391323cbdc02afc8099289e1aa7 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 16:14:14 +0200 Subject: [PATCH 29/81] New ticket creation fix --- .../android/support/he/ui/HENewTicketScreen.kt | 17 +++++++++++------ .../android/support/he/ui/HESupportActivity.kt | 10 ++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) 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 82d2468e7933..8505d5dd5139 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 @@ -52,7 +52,12 @@ import org.wordpress.android.ui.dataview.compose.RemoteImage @Composable fun HENewTicketScreen( onBackClick: () -> Unit, - onSubmit: (category: SupportCategory, subject: String, siteAddress: String) -> Unit, + onSubmit: ( + category: SupportCategory, + subject: String, + messageText: String, + siteAddress: String, + ) -> Unit, userName: String = "", userEmail: String = "", userAvatarUrl: String? = null, @@ -78,7 +83,7 @@ fun HENewTicketScreen( isLoading = isSendingNewConversation, onClick = { selectedCategory?.let { category -> - onSubmit(category, subject, siteAddress) + onSubmit(category, subject, messageText, siteAddress) } } ) @@ -358,7 +363,7 @@ private fun HENewTicketScreenPreview() { AppThemeM3(isDarkTheme = false) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> }, + onSubmit = { _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -372,7 +377,7 @@ private fun HENewTicketScreenPreviewDark() { AppThemeM3(isDarkTheme = true) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> }, + onSubmit = { _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -386,7 +391,7 @@ private fun HENewTicketScreenWordPressPreview() { AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> }, + onSubmit = { _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -400,7 +405,7 @@ private fun HENewTicketScreenPreviewWordPressDark() { AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HENewTicketScreen( onBackClick = { }, - onSubmit = { _, _, _ -> }, + onSubmit = { _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null 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 81b24e0cdedf..8e6604b17b4a 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 @@ -24,6 +24,7 @@ import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.util.ToastUtils import org.wordpress.android.R +import kotlin.String @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { @@ -135,8 +136,13 @@ class HESupportActivity : AppCompatActivity() { val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() HENewTicketScreen( onBackClick = { viewModel.onBackFromDetailClick() }, - onSubmit = { category, subject, siteAddress -> - viewModel.onSendNewConversation() + onSubmit = { category, subject, messageText, siteAddress -> + viewModel.onSendNewConversation( + subject = subject, + message = messageText, + tags = listOf(category.name), + attachments = listOf() + ) }, userName = userInfo.userName, userEmail = userInfo.userEmail, From 98cbb1f77226519c99f996ab3cc307699056b119 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 17:03:47 +0200 Subject: [PATCH 30/81] Using snackbar for errors --- .../support/he/ui/HESupportActivity.kt | 141 ++++++++++-------- .../support/he/ui/HESupportViewModel.kt | 45 +++--- 2 files changed, 103 insertions(+), 83 deletions(-) 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 8e6604b17b4a..3384701b077e 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 @@ -4,12 +4,19 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle -import android.view.Gravity import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle @@ -22,9 +29,7 @@ import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 -import org.wordpress.android.util.ToastUtils import org.wordpress.android.R -import kotlin.String @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { @@ -48,26 +53,9 @@ class HESupportActivity : AppCompatActivity() { } ) observeNavigationEvents() - observeErrorEvents() viewModel.init() } - private fun observeErrorEvents() { - // Observe error messages and show them as Toast - lifecycleScope.launch { - viewModel.errorMessage.collect { errorType -> - val errorMessage = when (errorType) { - HESupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) - null -> null - HESupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) - } - errorMessage?.let { - ToastUtils.showToast(this@HESupportActivity, it, ToastUtils.Duration.LONG, Gravity.CENTER) - viewModel.clearError() - } - } - } - } private fun observeNavigationEvents() { lifecycleScope.launch { @@ -98,57 +86,80 @@ class HESupportActivity : AppCompatActivity() { @Composable private fun NavigableContent() { navController = rememberNavController() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val errorMessage by viewModel.errorMessage.collectAsState() - AppThemeM3 { - NavHost( - navController = navController, - startDestination = ConversationScreen.List.name - ) { - composable(route = ConversationScreen.List.name) { - HEConversationsListScreen( - conversations = viewModel.conversations, - isLoadingConversations = viewModel.isLoadingConversations, - onConversationClick = { conversation -> - viewModel.onConversationClick(conversation) - }, - onBackClick = { finish() }, - onCreateNewConversationClick = { - viewModel.onCreateNewConversation() - }, - onRefresh = { - viewModel.refreshConversations() - } - ) - } + // Show snackbar when error occurs + errorMessage?.let { errorType -> + val message = when (errorType) { + HESupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) + HESupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + } + scope.launch { + snackbarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Long + ) + viewModel.clearError() + } + } - composable(route = ConversationScreen.Detail.name) { - val selectedConversation by viewModel.selectedConversation.collectAsState() - selectedConversation?.let { conversation -> - HEConversationDetailScreen( - conversation = conversation, - onBackClick = { viewModel.onBackFromDetailClick() } + AppThemeM3 { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = ConversationScreen.List.name, + modifier = Modifier.padding(paddingValues) + ) { + composable(route = ConversationScreen.List.name) { + HEConversationsListScreen( + conversations = viewModel.conversations, + isLoadingConversations = viewModel.isLoadingConversations, + onConversationClick = { conversation -> + viewModel.onConversationClick(conversation) + }, + onBackClick = { finish() }, + onCreateNewConversationClick = { + viewModel.onCreateNewConversation() + }, + onRefresh = { + viewModel.refreshConversations() + } ) } - } - composable(route = ConversationScreen.NewTicket.name) { - val userInfo by viewModel.userInfo.collectAsState() - val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() - HENewTicketScreen( - onBackClick = { viewModel.onBackFromDetailClick() }, - onSubmit = { category, subject, messageText, siteAddress -> - viewModel.onSendNewConversation( - subject = subject, - message = messageText, - tags = listOf(category.name), - attachments = listOf() + composable(route = ConversationScreen.Detail.name) { + val selectedConversation by viewModel.selectedConversation.collectAsState() + selectedConversation?.let { conversation -> + HEConversationDetailScreen( + conversation = conversation, + onBackClick = { viewModel.onBackFromDetailClick() } ) - }, - userName = userInfo.userName, - userEmail = userInfo.userEmail, - userAvatarUrl = userInfo.avatarUrl, - isSendingNewConversation = isSendingNewConversation - ) + } + } + + composable(route = ConversationScreen.NewTicket.name) { + val userInfo by viewModel.userInfo.collectAsState() + val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() + HENewTicketScreen( + onBackClick = { viewModel.onBackFromDetailClick() }, + onSubmit = { category, subject, messageText, siteAddress -> + viewModel.onSendNewConversation( + subject = subject, + message = messageText, + tags = listOf(category.name), + attachments = listOf() + ) + }, + userName = userInfo.userName, + userEmail = userInfo.userEmail, + userAvatarUrl = userInfo.avatarUrl, + isSendingNewConversation = isSendingNewConversation + ) + } } } } 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 30a91b775105..88ecb4643091 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 @@ -3,6 +3,7 @@ package org.wordpress.android.support.he.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -31,7 +32,7 @@ class HESupportViewModel @Inject constructor( data object NavigateBack : NavigationEvent() } - private val _conversations = MutableStateFlow>(emptyList()) + private val _conversations = MutableStateFlow>(listOf()) val conversations: StateFlow> = _conversations.asStateFlow() private val _selectedConversation = MutableStateFlow(null) @@ -53,29 +54,37 @@ class HESupportViewModel @Inject constructor( val errorMessage: StateFlow = _errorMessage.asStateFlow() fun init() { - loadUserInfo() - loadConversations() - } - - private fun loadUserInfo() { viewModelScope.launch { - if (!accountStore.hasAccessToken()) { + // We need to check it this way because access token can be null or empty if not set + // So, we manually handle it here + val accessToken = if (accountStore.hasAccessToken()) { + accountStore.accessToken!! + } else { + null + } + if (accessToken == null) { _errorMessage.value = ErrorType.FORBIDDEN - _navigationEvents.emit(NavigationEvent.NavigateBack) - return@launch + appLogWrapper.e( + AppLog.T.SUPPORT, "Error opening HE conversations. The user has no valid access token" + ) + } else { + loadUserInfo(accessToken) + loadConversations() } - val accessToken = accountStore.accessToken!! - val account = accountStore.account - heSupportRepository.init(accessToken) - _userInfo.value = UserInfo( - accessToken = accessToken, - userName = account.displayName.ifEmpty { account.userName }, - userEmail = account.email, - avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } - ) } } + private fun loadUserInfo(accessToken: String) { + val account = accountStore.account + heSupportRepository.init(accessToken) + _userInfo.value = UserInfo( + accessToken = accessToken, + userName = account.displayName.ifEmpty { account.userName }, + userEmail = account.email, + avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } + ) + } + private fun loadConversations() { viewModelScope.launch { try { From 5d421d3ee7fd4ea85b682acbafeac4df22a956d6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 17:57:04 +0200 Subject: [PATCH 31/81] Error handling --- .../he/repository/HESupportRepository.kt | 26 ++++++++-- .../support/he/ui/HESupportViewModel.kt | 48 ++++++++++--------- WordPress/src/main/res/values/strings.xml | 2 +- 3 files changed, 49 insertions(+), 27 deletions(-) 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 704f9b4db591..5986ec2cc8e5 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 @@ -19,6 +19,15 @@ import kotlin.String private const val APPLICATION_ID = "jetpack" +sealed class CreateConversationResult { + data class Success(val conversation: SupportConversation) : CreateConversationResult() + + sealed class Error : CreateConversationResult() { + data object Unauthorized : Error() + data object GeneralError : Error() + } +} + class HESupportRepository @Inject constructor( private val appLogWrapper: AppLogWrapper, private val wpComApiClientProvider: WpComApiClientProvider, @@ -78,7 +87,7 @@ class HESupportRepository @Inject constructor( message: String, tags: List, attachments: List - ): SupportConversation? = withContext(ioDispatcher) { + ): CreateConversationResult = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportTickets().createSupportTicket( CreateSupportTicketParams( @@ -94,12 +103,21 @@ class HESupportRepository @Inject constructor( when (response) { is WpRequestResult.Success -> { val conversation = response.response.data - conversation.toSupportConversation() + CreateConversationResult.Success(conversation.toSupportConversation()) } else -> { - appLogWrapper.e(AppLog.T.SUPPORT, "Error creating support conversations: $response") - null + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error creating support conversation: $response" + ) + // Parse the response string to determine error type + val responseString = response.toString() + when { + responseString.contains("401") || responseString.contains("403") -> + CreateConversationResult.Error.Unauthorized + else -> CreateConversationResult.Error.GeneralError + } } } } 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 88ecb4643091..77c4bbd9feab 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 @@ -14,6 +14,7 @@ import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper 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.support.model.UserInfo import org.wordpress.android.util.AppLog @@ -132,30 +133,30 @@ class HESupportViewModel @Inject constructor( attachments: List ) { viewModelScope.launch { - try { - _isSendingNewConversation.value = true - val conversation = heSupportRepository.createConversation( - subject = subject, - message = message, - tags = tags, - attachments = attachments - ) - if (conversation == null) { + _isSendingNewConversation.value = true + + when (val result = heSupportRepository.createConversation( + subject = subject, + message = message, + tags = tags, + attachments = attachments + )) { + is CreateConversationResult.Success -> { + _selectedConversation.value = result.conversation + _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail(result.conversation)) + } + + is CreateConversationResult.Error.Unauthorized -> { + _errorMessage.value = ErrorType.FORBIDDEN + appLogWrapper.e(AppLog.T.SUPPORT, "Unauthorized error creating HE conversation") + } + + is CreateConversationResult.Error.GeneralError -> { _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e( - AppLog.T.SUPPORT, "Error creating HE conversation: result null" - ) - } else { - _selectedConversation.value = conversation - _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail(conversation)) + appLogWrapper.e(AppLog.T.SUPPORT, "General error creating HE conversation") } - } catch (throwable: Throwable) { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e( - AppLog.T.SUPPORT, "Error creating HE conversation: " + - "${throwable.message} - ${throwable.stackTraceToString()}" - ) } + _isSendingNewConversation.value = false } } @@ -164,5 +165,8 @@ class HESupportViewModel @Inject constructor( _errorMessage.value = null } - enum class ErrorType { GENERAL, FORBIDDEN } + enum class ErrorType { + GENERAL, + FORBIDDEN, + } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 77b75cc26e35..3d9f0bf72ca1 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5182,5 +5182,5 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> We\'ll email you at this address. Send Something wrong happened. Please try again later. - Sorry, you are not allowed to perform the action. + Sorry, you are not allowed to perform this action. From af8e1dd55a964b66c7d91e5d5bc0a87664fd1844 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 18:57:03 +0200 Subject: [PATCH 32/81] Answering conversation --- .../he/repository/HESupportRepository.kt | 38 +++++++++++++++++++ .../support/he/ui/HESupportViewModel.kt | 38 +++++++++++++++++++ 2 files changed, 76 insertions(+) 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 5986ec2cc8e5..7589caa0c434 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 @@ -10,6 +10,7 @@ import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.util.AppLog import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.AddMessageToSupportConversationParams import uniffi.wp_api.CreateSupportTicketParams import uniffi.wp_api.SupportConversationSummary import uniffi.wp_api.SupportMessageAuthor @@ -122,6 +123,43 @@ class HESupportRepository @Inject constructor( } } + suspend fun addMessageToConversation( + conversationId: Long, + message: String, + attachments: List + ): CreateConversationResult = withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportTickets().addMessageToSupportConversation( + conversationId = conversationId.toULong(), + params = AddMessageToSupportConversationParams( + message = message, + attachments = attachments, + ) + ) + } + + when (response) { + is WpRequestResult.Success -> { + val conversation = response.response.data + CreateConversationResult.Success(conversation.toSupportConversation()) + } + + else -> { + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error adding message to support conversation: $response" + ) + // Parse the response string to determine error type + val responseString = response.toString() + when { + responseString.contains("401") || responseString.contains("403") -> + CreateConversationResult.Error.Unauthorized + else -> CreateConversationResult.Error.GeneralError + } + } + } + } + private fun List.toSupportConversations(): List = map { SupportConversation( 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 77c4bbd9feab..a6cef4f12354 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 @@ -161,6 +161,44 @@ class HESupportViewModel @Inject constructor( } } + fun onAddMessageToConversation( + message: String, + attachments: List + ) { + viewModelScope.launch { + val selectedConversation = _selectedConversation.value + if (selectedConversation == null) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error answering a conversation: no conversation selected") + return@launch + } + + _isSendingNewConversation.value = true + + when (val result = heSupportRepository.addMessageToConversation( + conversationId = selectedConversation.id, + message = message, + attachments = attachments + )) { + is CreateConversationResult.Success -> { + _selectedConversation.value = result.conversation + // TODO refresh conversation and scroll to bottom + } + + is CreateConversationResult.Error.Unauthorized -> { + _errorMessage.value = ErrorType.FORBIDDEN + appLogWrapper.e(AppLog.T.SUPPORT, "Unauthorized error adding message to HE conversation") + } + + is CreateConversationResult.Error.GeneralError -> { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "General error adding message to HE conversation") + } + } + + _isSendingNewConversation.value = false + } + } + fun clearError() { _errorMessage.value = null } From cad1eec5d93b750b7761a02a6bdeff83888406a7 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 19:40:37 +0200 Subject: [PATCH 33/81] Adding some test to the repository --- .../he/repository/HESupportRepositoryTest.kt | 222 ++++++++++++++++++ .../support/he/ui/HESupportViewModelTest.kt | 48 +++- 2 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt 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 new file mode 100644 index 000000000000..8eef4d903d0f --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/support/he/repository/HESupportRepositoryTest.kt @@ -0,0 +1,222 @@ +package org.wordpress.android.support.he.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.model.SupportMessage +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.SupportConversationSummary +import uniffi.wp_api.SupportMessageAuthor +import java.util.Date + +@ExperimentalCoroutinesApi +class HESupportRepositoryTest : BaseUnitTest() { + @Mock + lateinit var appLogWrapper: AppLogWrapper + + @Mock + lateinit var wpComApiClientProvider: WpComApiClientProvider + + @Mock + lateinit var wpComApiClient: WpComApiClient + + private lateinit var repository: HESupportRepository + + private val testAccessToken = "test_access_token_123" + + @Before + fun setUp() { + whenever(wpComApiClientProvider.getWpComApiClient(testAccessToken)) + .thenReturn(wpComApiClient) + + repository = HESupportRepository( + appLogWrapper = appLogWrapper, + wpComApiClientProvider = wpComApiClientProvider, + ioDispatcher = testDispatcher() + ) + } + + @Test + fun `init sets access token`() { + // When + repository.init(testAccessToken) + + // Then - No exception thrown when using the repository + // The test passes if no exception is thrown + } + + @Test + fun `repository requires initialization before use`() = runTest { + // Given - repository not initialized + + // When/Then - Should throw when trying to use without init + try { + repository.loadConversations() + error("Expected exception was not thrown") + } catch (e: IllegalStateException) { + assertThat(e.message).contains("Repository not initialized") + } + } + + @Test + fun `loadConversations returns list when request succeeds`() = runTest { + // Given + repository.init(testAccessToken) + + val conversationSummary1 = createSupportConversationSummary(1L) + val conversationSummary2 = createSupportConversationSummary(2L) + val conversationList = listOf(conversationSummary1, conversationSummary2) + + // Create the actual response object using the concrete type + val mockHeaderMap = mock() + val responseObject = uniffi.wp_api.SupportTicketsRequestGetSupportConversationListResponse( + data = conversationList, + headerMap = mockHeaderMap + ) + + val successResponse = WpRequestResult.Success(responseObject) + + @Suppress("UNCHECKED_CAST") + whenever( + wpComApiClient.request>(any()) + ).thenReturn(successResponse as WpRequestResult>) + + // When + val result = repository.loadConversations() + + // Then + assertThat(result).hasSize(2) + assertThat(result[0]).isEqualTo(conversationSummary1.toSupportConversation()) + assertThat(result[1]).isEqualTo(conversationSummary2.toSupportConversation()) + } + + @Test + fun `loadConversations returns empty list when request fails`() = runTest { + // Given + repository.init(testAccessToken) + + val errorResponse: WpRequestResult> = + WpRequestResult.UnknownError(500.toUShort(), "Internal Server Error") + + whenever( + wpComApiClient.request>(any()) + ).thenReturn(errorResponse) + + // When + val result = repository.loadConversations() + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `loadConversation returns conversation when request succeeds`() = runTest { + // Given + repository.init(testAccessToken) + val conversationId = 123L + + val supportConversation = createSupportConversation(conversationId) + + // Create the actual response object using the concrete type + val mockHeaderMap = mock() + val responseObject = uniffi.wp_api.SupportTicketsRequestGetSupportConversationResponse( + data = supportConversation, + headerMap = mockHeaderMap + ) + + val successResponse = WpRequestResult.Success(responseObject) + + @Suppress("UNCHECKED_CAST") + whenever( + wpComApiClient.request(any()) + ).thenReturn(successResponse as WpRequestResult) + + // When + val result = repository.loadConversation(conversationId) + + // Then + assertThat(result).isEqualTo(supportConversation.toSupportConversation()) + } + + @Test + fun `loadConversation returns null when request fails`() = runTest { + // Given + repository.init(testAccessToken) + val conversationId = 123L + + val errorResponse: WpRequestResult = + WpRequestResult.UnknownError(404.toUShort(), "Not Found") + + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + + // When + val result = repository.loadConversation(conversationId) + + // Then + assertThat(result).isNull() + } + + private fun createSupportConversationSummary(id: Long): SupportConversationSummary = + SupportConversationSummary( + id = id.toULong(), + title = "Test Conversation $id", + description = "Description $id", + status = "open", + createdAt = Date(System.currentTimeMillis()), + updatedAt = Date(System.currentTimeMillis()) + ) + + private fun createSupportConversation(id: Long): uniffi.wp_api.SupportConversation = + uniffi.wp_api.SupportConversation( + id = id.toULong(), + title = "Test Conversation $id", + description = "Description $id", + status = "open", + createdAt = Date(System.currentTimeMillis()), + updatedAt = Date(System.currentTimeMillis()), + messages = emptyList() + ) + + private fun SupportConversationSummary.toSupportConversation(): SupportConversation = + SupportConversation( + id = id.toLong(), + title = title, + description = description, + lastMessageSentAt = updatedAt, + messages = emptyList() + ) + + private fun uniffi.wp_api.SupportConversation.toSupportConversation(): SupportConversation = + SupportConversation( + id = this.id.toLong(), + title = this.title, + description = this.description, + lastMessageSentAt = this.updatedAt, + messages = this.messages.map { it.toSupportMessage() } + ) + + private fun uniffi.wp_api.SupportMessage.toSupportMessage(): SupportMessage = + SupportMessage( + id = this.id.toLong(), + text = this.content, + createdAt = this.createdAt, + authorName = when (this.author) { + is SupportMessageAuthor.User -> (this.author as SupportMessageAuthor.User).v1.displayName + is SupportMessageAuthor.SupportAgent -> (this.author as SupportMessageAuthor.SupportAgent).v1.name + }, + authorIsUser = this.author is SupportMessageAuthor.User + ) +} 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 ce48b4000f44..32ee20783f11 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 @@ -22,12 +22,20 @@ class HESupportViewModelTest : BaseUnitTest() { @Mock lateinit var account: AccountModel + @Mock + lateinit var heSupportRepository: org.wordpress.android.support.he.repository.HESupportRepository + + @Mock + lateinit var appLogWrapper: org.wordpress.android.fluxc.utils.AppLogWrapper + private lateinit var viewModel: HESupportViewModel @Before fun setUp() { viewModel = HESupportViewModel( - accountStore = accountStore + accountStore = accountStore, + heSupportRepository = heSupportRepository, + appLogWrapper = appLogWrapper ) } @@ -158,14 +166,28 @@ class HESupportViewModelTest : BaseUnitTest() { // region onSendNewConversation() tests @Test - fun `onSendNewConversation emits NavigateBack event`() = test { + fun `onSendNewConversation emits NavigateToConversationDetail event on success`() = test { + // Given + val testConversation = createTestConversation() + whenever(heSupportRepository.createConversation( + subject = "Test Subject", + message = "Test Message", + tags = emptyList(), + attachments = emptyList() + )).thenReturn(org.wordpress.android.support.he.repository.CreateConversationResult.Success(testConversation)) + // When viewModel.navigationEvents.test { - viewModel.onSendNewConversation() + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = emptyList(), + attachments = emptyList() + ) // Then val event = awaitItem() - assertThat(event).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) + assertThat(event).isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) } } @@ -219,17 +241,31 @@ class HESupportViewModelTest : BaseUnitTest() { @Test fun `can create new ticket and send in sequence`() = test { + // Given + val testConversation = createTestConversation() + whenever(heSupportRepository.createConversation( + subject = "Test", + message = "Test", + tags = emptyList(), + attachments = emptyList() + )).thenReturn(org.wordpress.android.support.he.repository.CreateConversationResult.Success(testConversation)) + // When viewModel.navigationEvents.test { viewModel.onCreateNewConversation() val firstEvent = awaitItem() - viewModel.onSendNewConversation() + viewModel.onSendNewConversation( + subject = "Test", + message = "Test", + tags = emptyList(), + attachments = emptyList() + ) val secondEvent = awaitItem() // Then assertThat(firstEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateToNewTicket) - assertThat(secondEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) + assertThat(secondEvent).isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) } } From 481ae1189174096055ead143a0137587bd85fb26 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 21 Oct 2025 19:50:42 +0200 Subject: [PATCH 34/81] More tests! --- .../he/repository/HESupportRepositoryTest.kt | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) 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 8eef4d903d0f..894a3aa22e6c 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 @@ -169,6 +169,176 @@ class HESupportRepositoryTest : BaseUnitTest() { assertThat(result).isNull() } + @Test + fun `createConversation returns success when request succeeds`() = runTest { + // Given + repository.init(testAccessToken) + val subject = "Test Subject" + val message = "Test Message" + val tags = listOf("tag1", "tag2") + val attachments = listOf("attachment1.jpg") + + val supportConversation = createSupportConversation(1L) + + // Create the actual response object using the concrete type + val mockHeaderMap = mock() + val responseObject = uniffi.wp_api.SupportTicketsRequestCreateSupportTicketResponse( + data = supportConversation, + headerMap = mockHeaderMap + ) + + val successResponse = WpRequestResult.Success(responseObject) + + @Suppress("UNCHECKED_CAST") + whenever( + wpComApiClient.request(any()) + ).thenReturn(successResponse as WpRequestResult) + + // When + val result = repository.createConversation( + subject = subject, + message = message, + tags = tags, + attachments = attachments + ) + + // Then + assertThat(result).isInstanceOf(CreateConversationResult.Success::class.java) + val successResult = result as CreateConversationResult.Success + assertThat(successResult.conversation).isEqualTo(supportConversation.toSupportConversation()) + } + + @Test + fun `createConversation returns Unauthorized when request fails with 401`() = runTest { + // Given + repository.init(testAccessToken) + + val errorResponse: WpRequestResult = + WpRequestResult.UnknownError(401.toUShort(), "Unauthorized") + + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + + // When + val result = repository.createConversation( + subject = "Test", + message = "Test", + tags = emptyList(), + attachments = emptyList() + ) + + // Then + assertThat(result).isInstanceOf(CreateConversationResult.Error.Unauthorized::class.java) + } + + @Test + fun `createConversation returns GeneralError when request fails with non-auth error`() = runTest { + // Given + repository.init(testAccessToken) + + val errorResponse: WpRequestResult = + WpRequestResult.UnknownError(500.toUShort(), "Internal Server Error") + + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + + // When + val result = repository.createConversation( + subject = "Test", + message = "Test", + tags = emptyList(), + attachments = emptyList() + ) + + // Then + assertThat(result).isInstanceOf(CreateConversationResult.Error.GeneralError::class.java) + } + + @Test + fun `addMessageToConversation returns success when request succeeds`() = runTest { + // Given + repository.init(testAccessToken) + val conversationId = 456L + val message = "Test Reply Message" + val attachments = listOf("reply-attachment.jpg") + + val supportConversation = createSupportConversation(conversationId) + + // Create the actual response object using the concrete type + val mockHeaderMap = mock() + val responseObject = uniffi.wp_api.SupportTicketsRequestAddMessageToSupportConversationResponse( + data = supportConversation, + headerMap = mockHeaderMap + ) + + val successResponse = WpRequestResult.Success(responseObject) + + @Suppress("UNCHECKED_CAST") + whenever( + wpComApiClient.request(any()) + ).thenReturn(successResponse as WpRequestResult) + + // When + val result = repository.addMessageToConversation( + conversationId = conversationId, + message = message, + attachments = attachments + ) + + // Then + assertThat(result).isInstanceOf(CreateConversationResult.Success::class.java) + val successResult = result as CreateConversationResult.Success + assertThat(successResult.conversation).isEqualTo(supportConversation.toSupportConversation()) + } + + @Test + fun `addMessageToConversation returns Unauthorized when request fails with 403`() = runTest { + // Given + repository.init(testAccessToken) + + val errorResponse: WpRequestResult = + WpRequestResult.UnknownError(403.toUShort(), "Forbidden") + + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + + // When + val result = repository.addMessageToConversation( + conversationId = 456L, + message = "Test", + attachments = emptyList() + ) + + // Then + assertThat(result).isInstanceOf(CreateConversationResult.Error.Unauthorized::class.java) + } + + @Test + fun `addMessageToConversation returns GeneralError when request fails with non-auth error`() = runTest { + // Given + repository.init(testAccessToken) + + val errorResponse: WpRequestResult = + WpRequestResult.UnknownError(500.toUShort(), "Internal Server Error") + + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + + // When + val result = repository.addMessageToConversation( + conversationId = 456L, + message = "Test", + attachments = emptyList() + ) + + // Then + assertThat(result).isInstanceOf(CreateConversationResult.Error.GeneralError::class.java) + } + private fun createSupportConversationSummary(id: Long): SupportConversationSummary = SupportConversationSummary( id = id.toULong(), From 95c80bd181328ba3e69dcfa115e2e0747948176e Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 10:56:09 +0200 Subject: [PATCH 35/81] Compile fixes --- .../android/support/he/ui/HEConversationDetailScreen.kt | 6 ++++-- .../android/support/he/ui/HEConversationsListScreen.kt | 7 ++++++- .../wordpress/android/support/he/ui/HESupportViewModel.kt | 3 +-- .../wordpress/android/support/main/ui/SupportActivity.kt | 6 ------ .../wordpress/android/support/main/ui/SupportViewModel.kt | 3 ++- 5 files changed, 13 insertions(+), 12 deletions(-) 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 7ffd8c942e9a..328ccf07e0d1 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 @@ -36,6 +36,7 @@ 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.text.font.FontWeight @@ -59,6 +60,7 @@ fun HEConversationDetailScreen( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() var showBottomSheet by remember { mutableStateOf(false) } + val resources = LocalResources.current Scaffold( topBar = { @@ -87,7 +89,7 @@ fun HEConversationDetailScreen( item { ConversationHeader( messageCount = conversation.messages.size, - lastUpdated = formatRelativeTime(conversation.lastMessageSentAt) + lastUpdated = formatRelativeTime(conversation.lastMessageSentAt, resources) ) } @@ -102,7 +104,7 @@ fun HEConversationDetailScreen( MessageItem( authorName = message.authorName, messageText = message.text, - timestamp = formatRelativeTime(message.createdAt), + timestamp = formatRelativeTime(message.createdAt, resources), isUserMessage = message.authorIsUser ) } 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 be40412060c6..bdfab9537905 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 @@ -1,6 +1,7 @@ package org.wordpress.android.support.he.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 @@ -30,6 +31,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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 @@ -99,6 +101,7 @@ private fun ShowConversationsList( ) { val conversationsList by conversations.collectAsState() val isLoading by isLoadingConversations.collectAsState() + val resources = LocalResources.current PullToRefreshBox( isRefreshing = isLoading, @@ -126,6 +129,7 @@ private fun ShowConversationsList( ) { conversation -> ConversationCard( conversation = conversation, + resources = resources, onClick = { onConversationClick(conversation) } ) Spacer(modifier = Modifier.height(12.dp)) @@ -142,6 +146,7 @@ private fun ShowConversationsList( @Composable private fun ConversationCard( conversation: SupportConversation, + resources: Resources, onClick: () -> Unit ) { Card( @@ -177,7 +182,7 @@ private fun ConversationCard( ) Text( - text = formatRelativeTime(conversation.lastMessageSentAt), + text = formatRelativeTime(conversation.lastMessageSentAt, resources), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp) 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 a6cef4f12354..2a16288ada12 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 @@ -3,7 +3,6 @@ package org.wordpress.android.support.he.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -39,7 +38,7 @@ class HESupportViewModel @Inject constructor( private val _selectedConversation = MutableStateFlow(null) val selectedConversation: StateFlow = _selectedConversation.asStateFlow() - private val _userInfo = MutableStateFlow(UserInfo()) + private val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) val userInfo: StateFlow = _userInfo.asStateFlow() private val _navigationEvents = MutableSharedFlow() diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt index dfbf18165aa0..989e7d0ec595 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt @@ -96,12 +96,6 @@ class SupportActivity : AppCompatActivity() { ) } - private fun navigateToAskTheHappinessEngineers() { - startActivity( - HESupportActivity.Companion.createIntent(this) - ) - } - private fun navigateToLogin() { if (BuildConfig.IS_JETPACK_APP) { ActivityLauncher.showSignInForResultJetpackOnly(this) diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index 54029b347e11..9fd332823318 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -36,7 +36,7 @@ class SupportViewModel @Inject constructor( val showAskHappinessEngineers: Boolean = true ) - private val _userInfo = MutableStateFlow(UserInfo()) + private val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) val userInfo: StateFlow = _userInfo.asStateFlow() private val _optionsVisibility = MutableStateFlow(SupportOptionsVisibility()) @@ -54,6 +54,7 @@ class SupportViewModel @Inject constructor( val account = accountStore.account _userInfo.value = UserInfo( + accessToken = accountStore.accessToken!!, userName = account.displayName.ifEmpty { account.userName }, userEmail = account.email, avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } From d36882ea4a38f3233d5788a0130b8136215dbe5c Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 11:09:20 +0200 Subject: [PATCH 36/81] Similarities improvements --- .../support/aibot/ui/AIBotSupportActivity.kt | 26 +++--------- .../support/aibot/ui/AIBotSupportViewModel.kt | 40 +++++++++++++++++-- .../support/he/ui/HESupportViewModel.kt | 39 ++++++++++-------- .../support/main/ui/SupportActivity.kt | 15 +++---- .../support/main/ui/SupportViewModel.kt | 23 +++++------ 5 files changed, 79 insertions(+), 64 deletions(-) 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 3c3700b73ac8..509c770302fe 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 @@ -30,11 +30,9 @@ class AIBotSupportActivity : AppCompatActivity() { private lateinit var composeView: ComposeView private lateinit var navController: NavHostController - private lateinit var userName: String override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - userName = intent.getStringExtra(USERNAME).orEmpty() composeView = ComposeView(this) setContentView( composeView.apply { @@ -47,16 +45,13 @@ class AIBotSupportActivity : AppCompatActivity() { } } ) - viewModel.init( - accessToken = intent.getStringExtra(ACCESS_TOKEN_ID)!!, - userId = intent.getLongExtra(USER_ID, 0) - ) // Observe error messages and show them as Toast lifecycleScope.launch { viewModel.errorMessage.collect { errorType -> val errorMessage = when (errorType) { AIBotSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) + AIBotSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) null -> null } errorMessage?.let { @@ -65,6 +60,8 @@ class AIBotSupportActivity : AppCompatActivity() { } } } + + viewModel.init() } private enum class ConversationScreen { @@ -108,9 +105,10 @@ class AIBotSupportActivity : AppCompatActivity() { val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isBotTyping by viewModel.isBotTyping.collectAsState() val canSendMessage by viewModel.canSendMessage.collectAsState() + val userInfo by viewModel.userInfo.collectAsState() selectedConversation?.let { conversation -> ConversationDetailScreen( - userName = userName, + userName = userInfo.userName, conversation = conversation, isLoading = isLoadingConversation, isBotTyping = isBotTyping, @@ -127,19 +125,7 @@ class AIBotSupportActivity : AppCompatActivity() { } companion object { - private const val ACCESS_TOKEN_ID = "arg_access_token_id" - private const val USER_ID = "arg_user_id" - private const val USERNAME = "arg_username" @JvmStatic - fun createIntent( - context: Context, - accessToken: String, - userId: Long, - userName: String, - ): Intent = Intent(context, AIBotSupportActivity::class.java).apply { - putExtra(ACCESS_TOKEN_ID, accessToken) - putExtra(USER_ID, userId) - putExtra(USERNAME, userName) - } + fun createIntent(context: Context): Intent = Intent(context, AIBotSupportActivity::class.java) } } 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 a5a7388d9537..dd19e0c0b820 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 @@ -7,10 +7,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +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.he.ui.HESupportViewModel +import org.wordpress.android.support.model.UserInfo import org.wordpress.android.util.AppLog import java.util.Date import javax.inject.Inject @@ -18,6 +21,7 @@ import kotlin.Long @HiltViewModel class AIBotSupportViewModel @Inject constructor( + private val accountStore: AccountStore, private val aiBotSupportRepository: AIBotSupportRepository, private val appLogWrapper: AppLogWrapper, ) : ViewModel() { @@ -42,12 +46,30 @@ class AIBotSupportViewModel @Inject constructor( private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + private val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) + val userInfo: StateFlow = _userInfo.asStateFlow() + @Suppress("TooGenericExceptionCaught") - fun init(accessToken: String, userId: Long) { + fun init() { viewModelScope.launch { try { - aiBotSupportRepository.init(accessToken, userId) - loadConversations() + // We need to check it this way because access token can be null or empty if not set + // So, we manually handle it here + val accessToken = if (accountStore.hasAccessToken()) { + accountStore.accessToken!! + } else { + null + } + if (accessToken == null) { + _errorMessage.value = ErrorType.FORBIDDEN + appLogWrapper.e( + AppLog.T.SUPPORT, "Error opening the AI bot conversations. The user has no valid access token" + ) + } else { + aiBotSupportRepository.init(accessToken, accountStore.account.id.toLong()) + loadUserInfo(accessToken) + loadConversations() + } } catch (throwable: Throwable) { _errorMessage.value = ErrorType.GENERAL appLogWrapper.e(AppLog.T.SUPPORT, "Error initialising the AI bot support repository: " + @@ -56,6 +78,16 @@ class AIBotSupportViewModel @Inject constructor( } } + private fun loadUserInfo(accessToken: String) { + val account = accountStore.account + _userInfo.value = UserInfo( + accessToken = accessToken, + userName = account.displayName.ifEmpty { account.userName }, + userEmail = account.email, + avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } + ) + } + @Suppress("TooGenericExceptionCaught") private suspend fun loadConversations() { try { @@ -190,5 +222,5 @@ class AIBotSupportViewModel @Inject constructor( } } - enum class ErrorType { GENERAL } + enum class ErrorType { GENERAL, FORBIDDEN } } 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 2a16288ada12..5b22f8469259 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 @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.support.aibot.ui.AIBotSupportViewModel import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository @@ -55,28 +56,34 @@ class HESupportViewModel @Inject constructor( fun init() { viewModelScope.launch { - // We need to check it this way because access token can be null or empty if not set - // So, we manually handle it here - val accessToken = if (accountStore.hasAccessToken()) { - accountStore.accessToken!! - } else { - null - } - if (accessToken == null) { - _errorMessage.value = ErrorType.FORBIDDEN - appLogWrapper.e( - AppLog.T.SUPPORT, "Error opening HE conversations. The user has no valid access token" - ) - } else { - loadUserInfo(accessToken) - loadConversations() + try { + // We need to check it this way because access token can be null or empty if not set + // So, we manually handle it here + val accessToken = if (accountStore.hasAccessToken()) { + accountStore.accessToken!! + } else { + null + } + if (accessToken == null) { + _errorMessage.value = ErrorType.FORBIDDEN + appLogWrapper.e( + AppLog.T.SUPPORT, "Error opening HE conversations. The user has no valid access token" + ) + } else { + heSupportRepository.init(accessToken) + loadUserInfo(accessToken) + loadConversations() + } + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error initialising HE support repository: " + + "${throwable.message} - ${throwable.stackTraceToString()}") } } } private fun loadUserInfo(accessToken: String) { val account = accountStore.account - heSupportRepository.init(accessToken) _userInfo.value = UserInfo( accessToken = accessToken, userName = account.displayName.ifEmpty { account.userName }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt index 989e7d0ec595..fb2f837c6d65 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt @@ -68,14 +68,9 @@ class SupportActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.navigationEvents.collect { event -> when (event) { - is SupportViewModel.NavigationEvent.NavigateToAskTheBots -> { - navigateToAskTheBots(event.accessToken, event.userId, event.userName) - } - is SupportViewModel.NavigationEvent.NavigateToLogin -> { - navigateToLogin() - } - - SupportViewModel.NavigationEvent.NavigateToAskHappinessEngineers -> { + is SupportViewModel.NavigationEvent.NavigateToAskTheBots -> navigateToAskTheBots() + is SupportViewModel.NavigationEvent.NavigateToLogin -> navigateToLogin() + is SupportViewModel.NavigationEvent.NavigateToAskHappinessEngineers -> { navigateToAskTheHappinessEngineers() } } @@ -84,9 +79,9 @@ class SupportActivity : AppCompatActivity() { } } - private fun navigateToAskTheBots(accessToken: String, userId: Long, userName: String) { + private fun navigateToAskTheBots() { startActivity( - AIBotSupportActivity.Companion.createIntent(this, accessToken, userId, userName) + AIBotSupportActivity.Companion.createIntent(this) ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index 9fd332823318..ede9b7e98021 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -22,11 +22,7 @@ class SupportViewModel @Inject constructor( private val appLogWrapper: AppLogWrapper, ) : ViewModel() { sealed class NavigationEvent { - data class NavigateToAskTheBots( - val accessToken: String, - val userId: Long, - val userName: String - ) : NavigationEvent() + data object NavigateToAskTheBots : NavigationEvent() data object NavigateToLogin : NavigationEvent() data object NavigateToAskHappinessEngineers : NavigationEvent() } @@ -77,21 +73,20 @@ class SupportViewModel @Inject constructor( if (!accountStore.hasAccessToken()) { appLogWrapper.d(AppLog.T.SUPPORT, "Trying to open a bot conversation without access token") } else { - val account = accountStore.account - _navigationEvents.emit( - NavigationEvent.NavigateToAskTheBots( - accessToken = accountStore.accessToken!!, // access token has been checked before - userId = account.userId, - userName = account.displayName.ifEmpty { account.userName } - ) - ) + _navigationEvents.emit(NavigationEvent.NavigateToAskTheBots) } } } fun onAskHappinessEngineersClick() { viewModelScope.launch { - _navigationEvents.emit(NavigationEvent.NavigateToAskHappinessEngineers) + // hasAccessToken() checks if it exists and it's not empty, not only the nullability. + // So, if it's true, then we are sure the token is not null + if (!accountStore.hasAccessToken()) { + appLogWrapper.d(AppLog.T.SUPPORT, "Trying to open a HE conversation without access token") + } else { + _navigationEvents.emit(NavigationEvent.NavigateToAskHappinessEngineers) + } } } From 563f58bea8659508619ee60ab1ffc3fbccefc0ca Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 11:17:51 +0200 Subject: [PATCH 37/81] Using snackbar in bots activity --- .../support/aibot/ui/AIBotSupportActivity.kt | 132 ++++++++++-------- 1 file changed, 72 insertions(+), 60 deletions(-) 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 509c770302fe..5adb270b34b1 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 @@ -4,15 +4,21 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle -import android.view.Gravity import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -21,7 +27,6 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 -import org.wordpress.android.util.ToastUtils @AndroidEntryPoint class AIBotSupportActivity : AppCompatActivity() { @@ -45,22 +50,6 @@ class AIBotSupportActivity : AppCompatActivity() { } } ) - - // Observe error messages and show them as Toast - lifecycleScope.launch { - viewModel.errorMessage.collect { errorType -> - val errorMessage = when (errorType) { - AIBotSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) - AIBotSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) - null -> null - } - errorMessage?.let { - ToastUtils.showToast(this@AIBotSupportActivity, it, ToastUtils.Duration.LONG, Gravity.CENTER) - viewModel.clearError() - } - } - } - viewModel.init() } @@ -72,53 +61,76 @@ class AIBotSupportActivity : AppCompatActivity() { @Composable private fun NavigableContent() { navController = rememberNavController() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val errorMessage by viewModel.errorMessage.collectAsState() + + // Show snackbar when error occurs + errorMessage?.let { errorType -> + val message = when (errorType) { + AIBotSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) + AIBotSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + } + scope.launch { + snackbarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Long + ) + viewModel.clearError() + } + } AppThemeM3 { - NavHost( - navController = navController, - startDestination = ConversationScreen.List.name - ) { - composable(route = ConversationScreen.List.name) { - val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() - ConversationsListScreen( - conversations = viewModel.conversations, - isLoading = isLoadingConversations, - onConversationClick = { conversation -> - viewModel.onConversationSelected(conversation) - navController.navigate(ConversationScreen.Detail.name) - }, - onBackClick = { finish() }, - onCreateNewConversationClick = { - viewModel.onNewConversationClicked() - viewModel.selectedConversation.value?.let { newConversation -> + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = ConversationScreen.List.name, + modifier = Modifier.padding(paddingValues) + ) { + composable(route = ConversationScreen.List.name) { + val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + ConversationsListScreen( + conversations = viewModel.conversations, + isLoading = isLoadingConversations, + onConversationClick = { conversation -> + viewModel.onConversationSelected(conversation) navController.navigate(ConversationScreen.Detail.name) - } - }, - onRefresh = { - viewModel.refreshConversations() - } - ) - } - - composable(route = ConversationScreen.Detail.name) { - val selectedConversation by viewModel.selectedConversation.collectAsState() - val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() - val isBotTyping by viewModel.isBotTyping.collectAsState() - val canSendMessage by viewModel.canSendMessage.collectAsState() - val userInfo by viewModel.userInfo.collectAsState() - selectedConversation?.let { conversation -> - ConversationDetailScreen( - userName = userInfo.userName, - conversation = conversation, - isLoading = isLoadingConversation, - isBotTyping = isBotTyping, - canSendMessage = canSendMessage, - onBackClick = { navController.navigateUp() }, - onSendMessage = { text -> - viewModel.sendMessage(text) + }, + onBackClick = { finish() }, + onCreateNewConversationClick = { + viewModel.onNewConversationClicked() + viewModel.selectedConversation.value?.let { newConversation -> + navController.navigate(ConversationScreen.Detail.name) + } + }, + onRefresh = { + viewModel.refreshConversations() } ) } + + composable(route = ConversationScreen.Detail.name) { + val selectedConversation by viewModel.selectedConversation.collectAsState() + val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() + val isBotTyping by viewModel.isBotTyping.collectAsState() + val canSendMessage by viewModel.canSendMessage.collectAsState() + val userInfo by viewModel.userInfo.collectAsState() + selectedConversation?.let { conversation -> + ConversationDetailScreen( + userName = userInfo.userName, + conversation = conversation, + isLoading = isLoadingConversation, + isBotTyping = isBotTyping, + canSendMessage = canSendMessage, + onBackClick = { navController.navigateUp() }, + onSendMessage = { text -> + viewModel.sendMessage(text) + } + ) + } + } } } } From ab8ca28751d24a6b0f8bcecbe4fd9dac7f468a3f Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 11:21:00 +0200 Subject: [PATCH 38/81] Extracting EmptyConversationsView --- .../support/aibot/ui/AIBotSupportViewModel.kt | 4 +- .../aibot/ui/ConversationsListScreen.kt | 44 +------------ .../support/{ => common}/model/UserInfo.kt | 2 +- .../common/ui/EmptyConversationsView.kt | 62 +++++++++++++++++++ .../he/ui/HEConversationsListScreen.kt | 44 +------------ .../support/he/ui/HESupportViewModel.kt | 3 +- .../support/main/ui/SupportViewModel.kt | 2 +- 7 files changed, 68 insertions(+), 93 deletions(-) rename WordPress/src/main/java/org/wordpress/android/support/{ => common}/model/UserInfo.kt (73%) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/common/ui/EmptyConversationsView.kt 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 dd19e0c0b820..a7dd6093edb2 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 @@ -12,12 +12,10 @@ 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.he.ui.HESupportViewModel -import org.wordpress.android.support.model.UserInfo +import org.wordpress.android.support.common.model.UserInfo import org.wordpress.android.util.AppLog import java.util.Date import javax.inject.Inject -import kotlin.Long @HiltViewModel class AIBotSupportViewModel @Inject constructor( diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt index 8773f0c2b6f6..6e00e149e6e9 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt @@ -46,6 +46,7 @@ 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.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @@ -109,49 +110,6 @@ fun ConversationsListScreen( } } -@Composable -private fun EmptyConversationsView( - modifier: Modifier, - onCreateNewConversationClick: () -> Unit -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = "💬", - style = MaterialTheme.typography.displayLarge - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = stringResource(R.string.ai_bot_empty_conversations_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.padding(8.dp)) - - Text( - text = stringResource(R.string.ai_bot_empty_conversations_message), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.padding(24.dp)) - - Button(onClick = onCreateNewConversationClick) { - Text(text = stringResource(R.string.ai_bot_empty_conversations_button)) - } - } -} - @Composable private fun ShowConversationsList( modifier: Modifier, diff --git a/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt b/WordPress/src/main/java/org/wordpress/android/support/common/model/UserInfo.kt similarity index 73% rename from WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt rename to WordPress/src/main/java/org/wordpress/android/support/common/model/UserInfo.kt index a64b7aaa2c72..c859502f8043 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/model/UserInfo.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/model/UserInfo.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.support.model +package org.wordpress.android.support.common.model data class UserInfo( val accessToken: String, 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 new file mode 100644 index 000000000000..1f120063ed77 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/EmptyConversationsView.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.support.common.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.wordpress.android.R + +@Composable +fun EmptyConversationsView( + modifier: Modifier, + onCreateNewConversationClick: () -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "💬", + style = MaterialTheme.typography.displayLarge + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(R.string.he_support_empty_conversations_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.padding(8.dp)) + + Text( + text = stringResource(R.string.he_support_empty_conversations_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.padding(24.dp)) + + Button(onClick = onCreateNewConversationClick) { + Text(text = stringResource(R.string.he_support_empty_conversations_button)) + } + } +} 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 bdfab9537905..da2f8c447b04 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 @@ -43,6 +43,7 @@ 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.he.model.SupportConversation import org.wordpress.android.support.he.util.generateSampleHESupportConversations import org.wordpress.android.ui.compose.components.MainTopAppBar @@ -209,49 +210,6 @@ private fun ConversationCard( } } -@Composable -private fun EmptyConversationsView( - modifier: Modifier, - onCreateNewConversationClick: () -> Unit -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = "💬", - style = MaterialTheme.typography.displayLarge - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = stringResource(R.string.he_support_empty_conversations_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.padding(8.dp)) - - Text( - text = stringResource(R.string.he_support_empty_conversations_message), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.padding(24.dp)) - - Button(onClick = onCreateNewConversationClick) { - Text(text = stringResource(R.string.he_support_empty_conversations_button)) - } - } -} - @Preview(showBackground = true, name = "HE Support Conversations List") @Composable private fun ConversationsScreenPreview() { 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 5b22f8469259..f7bfb3700575 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 @@ -12,11 +12,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.support.aibot.ui.AIBotSupportViewModel 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.support.model.UserInfo +import org.wordpress.android.support.common.model.UserInfo import org.wordpress.android.util.AppLog import javax.inject.Inject import kotlin.String diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index ede9b7e98021..2f8a17e48dca 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.support.model.UserInfo +import org.wordpress.android.support.common.model.UserInfo import org.wordpress.android.util.AppLog import javax.inject.Inject From 034288e0647ee9da62f08a4d9388367762c37ba3 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 11:26:20 +0200 Subject: [PATCH 39/81] Renaming --- ...ationDetailScreen.kt => AIBotConversationDetailScreen.kt} | 0 ...rsationsListScreen.kt => AIBotConversationsListScreen.kt} | 5 ----- 2 files changed, 5 deletions(-) rename WordPress/src/main/java/org/wordpress/android/support/aibot/ui/{ConversationDetailScreen.kt => AIBotConversationDetailScreen.kt} (100%) rename WordPress/src/main/java/org/wordpress/android/support/aibot/ui/{ConversationsListScreen.kt => AIBotConversationsListScreen.kt} (97%) diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt similarity index 100% rename from WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationDetailScreen.kt rename to WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt similarity index 97% rename from WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt rename to WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt index 6e00e149e6e9..d52ff5048727 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt @@ -9,13 +9,11 @@ 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.automirrored.filled.ArrowBack -import androidx.compose.material3.Button import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -30,12 +28,9 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -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.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp From 049df3ef5b994e7536a1f7511aa981f90786809a Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 11:50:25 +0200 Subject: [PATCH 40/81] Extracting VM and UI common code --- .../support/aibot/ui/AIBotSupportActivity.kt | 5 +- .../support/aibot/ui/AIBotSupportViewModel.kt | 89 ++------------- .../support/common/ui/SupportViewModel.kt | 106 ++++++++++++++++++ .../support/he/ui/HESupportActivity.kt | 5 +- .../support/he/ui/HESupportViewModel.kt | 93 ++------------- 5 files changed, 126 insertions(+), 172 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt 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 5adb270b34b1..5cf095e37a7a 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 @@ -26,6 +26,7 @@ import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.R +import org.wordpress.android.support.common.ui.SupportViewModel import org.wordpress.android.ui.compose.theme.AppThemeM3 @AndroidEntryPoint @@ -68,8 +69,8 @@ class AIBotSupportActivity : AppCompatActivity() { // Show snackbar when error occurs errorMessage?.let { errorType -> val message = when (errorType) { - AIBotSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) - AIBotSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + SupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) + SupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) } scope.launch { snackbarHostState.showSnackbar( 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 a7dd6093edb2..f0c435cc53ba 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 @@ -1,6 +1,5 @@ package org.wordpress.android.support.aibot.ui -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -12,103 +11,31 @@ 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.model.UserInfo +import org.wordpress.android.support.common.ui.SupportViewModel import org.wordpress.android.util.AppLog import java.util.Date import javax.inject.Inject @HiltViewModel class AIBotSupportViewModel @Inject constructor( - private val accountStore: AccountStore, + accountStore: AccountStore, private val aiBotSupportRepository: AIBotSupportRepository, - private val appLogWrapper: AppLogWrapper, -) : ViewModel() { - private val _conversations = MutableStateFlow>(emptyList()) - val conversations: StateFlow> = _conversations.asStateFlow() - - private val _selectedConversation = MutableStateFlow(null) - val selectedConversation: StateFlow = _selectedConversation.asStateFlow() - + appLogWrapper: AppLogWrapper, +) : SupportViewModel(accountStore, appLogWrapper) { private val _canSendMessage = MutableStateFlow(true) val canSendMessage: StateFlow = _canSendMessage.asStateFlow() private val _isLoadingConversation = MutableStateFlow(false) val isLoadingConversation: StateFlow = _isLoadingConversation.asStateFlow() - private val _isLoadingConversations = MutableStateFlow(false) - val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() - private val _isBotTyping = MutableStateFlow(false) val isBotTyping: StateFlow = _isBotTyping.asStateFlow() - private val _errorMessage = MutableStateFlow(null) - val errorMessage: StateFlow = _errorMessage.asStateFlow() - - private val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) - val userInfo: StateFlow = _userInfo.asStateFlow() - - @Suppress("TooGenericExceptionCaught") - fun init() { - viewModelScope.launch { - try { - // We need to check it this way because access token can be null or empty if not set - // So, we manually handle it here - val accessToken = if (accountStore.hasAccessToken()) { - accountStore.accessToken!! - } else { - null - } - if (accessToken == null) { - _errorMessage.value = ErrorType.FORBIDDEN - appLogWrapper.e( - AppLog.T.SUPPORT, "Error opening the AI bot conversations. The user has no valid access token" - ) - } else { - aiBotSupportRepository.init(accessToken, accountStore.account.id.toLong()) - loadUserInfo(accessToken) - loadConversations() - } - } catch (throwable: Throwable) { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error initialising the AI bot support repository: " + - "${throwable.message} - ${throwable.stackTraceToString()}") - } - } + override fun initRepository(accessToken: String) { + aiBotSupportRepository.init(accessToken, accountStore.account.id.toLong()) } - private fun loadUserInfo(accessToken: String) { - val account = accountStore.account - _userInfo.value = UserInfo( - accessToken = accessToken, - userName = account.displayName.ifEmpty { account.userName }, - userEmail = account.email, - avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } - ) - } - - @Suppress("TooGenericExceptionCaught") - private suspend fun loadConversations() { - try { - _isLoadingConversations.value = true - val conversations = aiBotSupportRepository.loadConversations() - _conversations.value = conversations - } catch (throwable: Throwable) { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversations: " + - "${throwable.message} - ${throwable.stackTraceToString()}") - } - _isLoadingConversations.value = false - } - - fun refreshConversations() { - viewModelScope.launch { - loadConversations() - } - } - - fun clearError() { - _errorMessage.value = null - } + override suspend fun getConversations() = aiBotSupportRepository.loadConversations() @Suppress("TooGenericExceptionCaught") fun onConversationSelected(conversation: BotConversation) { @@ -219,6 +146,4 @@ class AIBotSupportViewModel @Inject constructor( aiBotSupportRepository.sendMessageToConversation(conversationId, message) } } - - enum class ErrorType { GENERAL, FORBIDDEN } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt new file mode 100644 index 000000000000..b7721e4d38b3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt @@ -0,0 +1,106 @@ +package org.wordpress.android.support.common.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.support.common.model.UserInfo +import org.wordpress.android.util.AppLog + +abstract class SupportViewModel( + protected val accountStore: AccountStore, + protected val appLogWrapper: AppLogWrapper, +) : ViewModel() { + protected val _conversations = MutableStateFlow>(emptyList()) + val conversations: StateFlow> = _conversations.asStateFlow() + + protected val _selectedConversation = MutableStateFlow(null) + val selectedConversation: StateFlow = _selectedConversation.asStateFlow() + + protected val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) + val userInfo: StateFlow = _userInfo.asStateFlow() + + protected val _isLoadingConversations = MutableStateFlow(false) + val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() + + protected val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + @Suppress("TooGenericExceptionCaught") + fun init() { + viewModelScope.launch { + try { + // We need to check it this way because access token can be null or empty if not set + // So, we manually handle it here + val accessToken = if (accountStore.hasAccessToken()) { + accountStore.accessToken!! + } else { + null + } + if (accessToken == null) { + _errorMessage.value = ErrorType.FORBIDDEN + appLogWrapper.e( + AppLog.T.SUPPORT, "Error initialising support conversations: The user has no valid access token" + ) + } else { + initRepository(accessToken) + loadUserInfo(accessToken) + loadConversations() + } + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error initialising support conversations: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } + } + } + + abstract fun initRepository(accessToken: String) + + protected fun loadUserInfo(accessToken: String) { + val account = accountStore.account + _userInfo.value = UserInfo( + accessToken = accessToken, + userName = account.displayName.ifEmpty { account.userName }, + userEmail = account.email, + avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } + ) + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadConversations() { + try { + _isLoadingConversations.value = true + val conversations = getConversations() + _conversations.value = conversations + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e( + AppLog.T.SUPPORT, "Error loading support conversations: " + + "${throwable.message} - ${throwable.stackTraceToString()}" + ) + } + _isLoadingConversations.value = false + } + + protected abstract suspend fun getConversations(): List + + fun refreshConversations() { + viewModelScope.launch { + loadConversations() + } + } + + fun clearError() { + _errorMessage.value = null + } + + enum class ErrorType { + GENERAL, + FORBIDDEN, + } +} 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 3384701b077e..627ed9766afb 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 @@ -30,6 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R +import org.wordpress.android.support.common.ui.SupportViewModel @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { @@ -93,8 +94,8 @@ class HESupportActivity : AppCompatActivity() { // Show snackbar when error occurs errorMessage?.let { errorType -> val message = when (errorType) { - HESupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) - HESupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + SupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) + SupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) } scope.launch { snackbarHostState.showSnackbar( 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 f7bfb3700575..afe24f7bbb36 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 @@ -1,6 +1,5 @@ package org.wordpress.android.support.he.ui -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -12,105 +11,36 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.support.common.ui.SupportViewModel 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.support.common.model.UserInfo import org.wordpress.android.util.AppLog import javax.inject.Inject -import kotlin.String @HiltViewModel class HESupportViewModel @Inject constructor( - private val accountStore: AccountStore, + accountStore: AccountStore, private val heSupportRepository: HESupportRepository, - private val appLogWrapper: AppLogWrapper, -) : ViewModel() { + appLogWrapper: AppLogWrapper, +) : SupportViewModel(accountStore, appLogWrapper) { sealed class NavigationEvent { data class NavigateToConversationDetail(val conversation: SupportConversation) : NavigationEvent() data object NavigateToNewTicket : NavigationEvent() data object NavigateBack : NavigationEvent() } - private val _conversations = MutableStateFlow>(listOf()) - val conversations: StateFlow> = _conversations.asStateFlow() - - private val _selectedConversation = MutableStateFlow(null) - val selectedConversation: StateFlow = _selectedConversation.asStateFlow() - - private val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) - val userInfo: StateFlow = _userInfo.asStateFlow() - private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() - private val _isLoadingConversations = MutableStateFlow(false) - val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() - private val _isSendingNewConversation = MutableStateFlow(false) val isSendingNewConversation: StateFlow = _isSendingNewConversation.asStateFlow() - private val _errorMessage = MutableStateFlow(null) - val errorMessage: StateFlow = _errorMessage.asStateFlow() - - fun init() { - viewModelScope.launch { - try { - // We need to check it this way because access token can be null or empty if not set - // So, we manually handle it here - val accessToken = if (accountStore.hasAccessToken()) { - accountStore.accessToken!! - } else { - null - } - if (accessToken == null) { - _errorMessage.value = ErrorType.FORBIDDEN - appLogWrapper.e( - AppLog.T.SUPPORT, "Error opening HE conversations. The user has no valid access token" - ) - } else { - heSupportRepository.init(accessToken) - loadUserInfo(accessToken) - loadConversations() - } - } catch (throwable: Throwable) { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error initialising HE support repository: " + - "${throwable.message} - ${throwable.stackTraceToString()}") - } - } + override fun initRepository(accessToken: String) { + heSupportRepository.init(accessToken) } - private fun loadUserInfo(accessToken: String) { - val account = accountStore.account - _userInfo.value = UserInfo( - accessToken = accessToken, - userName = account.displayName.ifEmpty { account.userName }, - userEmail = account.email, - avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } - ) - } - - private fun loadConversations() { - viewModelScope.launch { - try { - _isLoadingConversations.value = true - val conversations = heSupportRepository.loadConversations() - _conversations.value = conversations - } catch (throwable: Throwable) { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e( - AppLog.T.SUPPORT, "Error loading HE conversations: " + - "${throwable.message} - ${throwable.stackTraceToString()}" - ) - } - _isLoadingConversations.value = false - } - } - - fun refreshConversations() { - loadConversations() - } + override suspend fun getConversations(): List = heSupportRepository.loadConversations() fun onConversationClick(conversation: SupportConversation) { viewModelScope.launch { @@ -203,13 +133,4 @@ class HESupportViewModel @Inject constructor( _isSendingNewConversation.value = false } } - - fun clearError() { - _errorMessage.value = null - } - - enum class ErrorType { - GENERAL, - FORBIDDEN, - } } From 15ab84ea43e7ad74d130acd7ee21029fa01d73d7 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 12:09:05 +0200 Subject: [PATCH 41/81] Extracting navigation common code --- .../support/aibot/ui/AIBotSupportActivity.kt | 32 ++++++++++++++--- .../support/aibot/ui/AIBotSupportViewModel.kt | 29 +++++++++------ .../support/common/ui/SupportViewModel.kt | 35 +++++++++++++++++++ .../support/he/ui/HESupportActivity.kt | 8 ++--- .../support/he/ui/HESupportViewModel.kt | 32 ++--------------- .../support/he/ui/HESupportViewModelTest.kt | 4 +-- 6 files changed, 89 insertions(+), 51 deletions(-) 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 5cf095e37a7a..88b7dfdc9767 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 @@ -19,6 +19,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -51,9 +54,32 @@ class AIBotSupportActivity : AppCompatActivity() { } } ) + observeNavigationEvents() viewModel.init() } + private fun observeNavigationEvents() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigationEvents.collect { event -> + when (event) { + is SupportViewModel.NavigationEvent.NavigateToConversationDetail -> { + navController.navigate(ConversationScreen.Detail.name) + } + SupportViewModel.NavigationEvent.NavigateBack -> { + navController.navigateUp() + } + + SupportViewModel.NavigationEvent.NavigateToNewConversation -> { + // New conversations are handled in the conversation details screen + navController.navigate(ConversationScreen.Detail.name) + } + } + } + } + } + } + private enum class ConversationScreen { List, Detail @@ -97,14 +123,10 @@ class AIBotSupportActivity : AppCompatActivity() { isLoading = isLoadingConversations, onConversationClick = { conversation -> viewModel.onConversationSelected(conversation) - navController.navigate(ConversationScreen.Detail.name) }, onBackClick = { finish() }, onCreateNewConversationClick = { viewModel.onNewConversationClicked() - viewModel.selectedConversation.value?.let { newConversation -> - navController.navigate(ConversationScreen.Detail.name) - } }, onRefresh = { viewModel.refreshConversations() @@ -125,7 +147,7 @@ class AIBotSupportActivity : AppCompatActivity() { isLoading = isLoadingConversation, isBotTyping = isBotTyping, canSendMessage = canSendMessage, - onBackClick = { navController.navigateUp() }, + onBackClick = { viewModel.onBackFromDetailClick() }, onSendMessage = { text -> viewModel.sendMessage(text) } 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 f0c435cc53ba..b5d23e15b555 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 @@ -2,8 +2,11 @@ package org.wordpress.android.support.aibot.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore @@ -22,6 +25,7 @@ class AIBotSupportViewModel @Inject constructor( private val aiBotSupportRepository: AIBotSupportRepository, appLogWrapper: AppLogWrapper, ) : SupportViewModel(accountStore, appLogWrapper) { + private val _canSendMessage = MutableStateFlow(true) val canSendMessage: StateFlow = _canSendMessage.asStateFlow() @@ -46,7 +50,8 @@ class AIBotSupportViewModel @Inject constructor( _canSendMessage.value = true val updatedConversation = aiBotSupportRepository.loadConversation(conversation.id) if (updatedConversation != null) { - _selectedConversation.value = updatedConversation + // Simulate clicking on the conversation + onConversationClick(updatedConversation) } else { _errorMessage.value = ErrorType.GENERAL appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation: " + @@ -62,15 +67,19 @@ class AIBotSupportViewModel @Inject constructor( } fun onNewConversationClicked() { - val now = Date() - _selectedConversation.value = BotConversation( - id = 0, - createdAt = now, - mostRecentMessageDate = now, - lastMessage = "", - messages = listOf() - ) - _canSendMessage.value = true + viewModelScope.launch { + val now = Date() + val botConversation = BotConversation( + id = 0, + createdAt = now, + mostRecentMessageDate = now, + lastMessage = "", + messages = listOf() + ) + _canSendMessage.value = true + // Simulate clicking on the conversation + onConversationClick(botConversation) + } } @Suppress("TooGenericExceptionCaught") diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt index b7721e4d38b3..f3f9603072d1 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt @@ -2,8 +2,11 @@ package org.wordpress.android.support.common.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore @@ -15,6 +18,15 @@ abstract class SupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, ) : ViewModel() { + sealed class NavigationEvent { + data object NavigateToConversationDetail : NavigationEvent() + data object NavigateToNewConversation : NavigationEvent() + data object NavigateBack : NavigationEvent() + } + + private val _navigationEvents = MutableSharedFlow() + val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + protected val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations.asStateFlow() @@ -99,6 +111,29 @@ abstract class SupportViewModel( _errorMessage.value = null } + // Region navigation + + fun onConversationClick(conversation: ConversationType) { + viewModelScope.launch { + _selectedConversation.value = conversation + _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) + } + } + + fun onBackFromDetailClick() { + viewModelScope.launch { + _navigationEvents.emit(NavigationEvent.NavigateBack) + } + } + + fun onCreateNewConversationClick() { + viewModelScope.launch { + _navigationEvents.emit(NavigationEvent.NavigateToNewConversation) + } + } + + // End region + enum class ErrorType { GENERAL, FORBIDDEN, 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 627ed9766afb..f201191ce172 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 @@ -63,13 +63,13 @@ class HESupportActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.navigationEvents.collect { event -> when (event) { - is HESupportViewModel.NavigationEvent.NavigateToConversationDetail -> { + is SupportViewModel.NavigationEvent.NavigateToConversationDetail -> { navController.navigate(ConversationScreen.Detail.name) } - HESupportViewModel.NavigationEvent.NavigateToNewTicket -> { + SupportViewModel.NavigationEvent.NavigateToNewConversation -> { navController.navigate(ConversationScreen.NewTicket.name) } - HESupportViewModel.NavigationEvent.NavigateBack -> { + SupportViewModel.NavigationEvent.NavigateBack -> { navController.navigateUp() } } @@ -124,7 +124,7 @@ class HESupportActivity : AppCompatActivity() { }, onBackClick = { finish() }, onCreateNewConversationClick = { - viewModel.onCreateNewConversation() + viewModel.onCreateNewConversationClick() }, onRefresh = { viewModel.refreshConversations() 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 afe24f7bbb36..b5199903c120 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 @@ -24,15 +24,6 @@ class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, appLogWrapper: AppLogWrapper, ) : SupportViewModel(accountStore, appLogWrapper) { - sealed class NavigationEvent { - data class NavigateToConversationDetail(val conversation: SupportConversation) : NavigationEvent() - data object NavigateToNewTicket : NavigationEvent() - data object NavigateBack : NavigationEvent() - } - - private val _navigationEvents = MutableSharedFlow() - val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() - private val _isSendingNewConversation = MutableStateFlow(false) val isSendingNewConversation: StateFlow = _isSendingNewConversation.asStateFlow() @@ -42,25 +33,6 @@ class HESupportViewModel @Inject constructor( override suspend fun getConversations(): List = heSupportRepository.loadConversations() - fun onConversationClick(conversation: SupportConversation) { - viewModelScope.launch { - _selectedConversation.value = conversation - _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail(conversation)) - } - } - - fun onBackFromDetailClick() { - viewModelScope.launch { - _navigationEvents.emit(NavigationEvent.NavigateBack) - } - } - - fun onCreateNewConversation() { - viewModelScope.launch { - _navigationEvents.emit(NavigationEvent.NavigateToNewTicket) - } - } - fun onSendNewConversation( subject: String, message: String, @@ -77,8 +49,8 @@ class HESupportViewModel @Inject constructor( attachments = attachments )) { is CreateConversationResult.Success -> { - _selectedConversation.value = result.conversation - _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail(result.conversation)) + // Simulate clicking on the conversation + onConversationClick(result.conversation) } is CreateConversationResult.Error.Unauthorized -> { 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 32ee20783f11..b505d014188e 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 @@ -153,7 +153,7 @@ class HESupportViewModelTest : BaseUnitTest() { fun `onCreateNewConversation emits NavigateToNewTicket event`() = test { // When viewModel.navigationEvents.test { - viewModel.onCreateNewConversation() + viewModel.onCreateNewConversationClick() // Then val event = awaitItem() @@ -252,7 +252,7 @@ class HESupportViewModelTest : BaseUnitTest() { // When viewModel.navigationEvents.test { - viewModel.onCreateNewConversation() + viewModel.onCreateNewConversationClick() val firstEvent = awaitItem() viewModel.onSendNewConversation( From 53085d09d66addaa48c4b48e451b11baac2603b0 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 12:10:19 +0200 Subject: [PATCH 42/81] Renaming VMs for clarification --- .../android/support/aibot/ui/AIBotSupportActivity.kt | 12 ++++++------ .../support/aibot/ui/AIBotSupportViewModel.kt | 7 ++----- ...ViewModel.kt => ConversationsSupportViewModel.kt} | 2 +- .../android/support/he/ui/HESupportActivity.kt | 12 ++++++------ .../android/support/he/ui/HESupportViewModel.kt | 7 ++----- 5 files changed, 17 insertions(+), 23 deletions(-) rename WordPress/src/main/java/org/wordpress/android/support/common/ui/{SupportViewModel.kt => ConversationsSupportViewModel.kt} (98%) 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 88b7dfdc9767..51cdddebac03 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 @@ -29,7 +29,7 @@ import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.R -import org.wordpress.android.support.common.ui.SupportViewModel +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.ui.compose.theme.AppThemeM3 @AndroidEntryPoint @@ -63,14 +63,14 @@ class AIBotSupportActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.navigationEvents.collect { event -> when (event) { - is SupportViewModel.NavigationEvent.NavigateToConversationDetail -> { + is ConversationsSupportViewModel.NavigationEvent.NavigateToConversationDetail -> { navController.navigate(ConversationScreen.Detail.name) } - SupportViewModel.NavigationEvent.NavigateBack -> { + ConversationsSupportViewModel.NavigationEvent.NavigateBack -> { navController.navigateUp() } - SupportViewModel.NavigationEvent.NavigateToNewConversation -> { + ConversationsSupportViewModel.NavigationEvent.NavigateToNewConversation -> { // New conversations are handled in the conversation details screen navController.navigate(ConversationScreen.Detail.name) } @@ -95,8 +95,8 @@ class AIBotSupportActivity : AppCompatActivity() { // Show snackbar when error occurs errorMessage?.let { errorType -> val message = when (errorType) { - SupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) - SupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) + ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) } scope.launch { snackbarHostState.showSnackbar( 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 b5d23e15b555..d40295fd6b69 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 @@ -2,11 +2,8 @@ package org.wordpress.android.support.aibot.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore @@ -14,7 +11,7 @@ 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.SupportViewModel +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.util.AppLog import java.util.Date import javax.inject.Inject @@ -24,7 +21,7 @@ class AIBotSupportViewModel @Inject constructor( accountStore: AccountStore, private val aiBotSupportRepository: AIBotSupportRepository, appLogWrapper: AppLogWrapper, -) : SupportViewModel(accountStore, appLogWrapper) { +) : ConversationsSupportViewModel(accountStore, appLogWrapper) { private val _canSendMessage = MutableStateFlow(true) val canSendMessage: StateFlow = _canSendMessage.asStateFlow() diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt similarity index 98% rename from WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt rename to WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt index f3f9603072d1..ae596838a392 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt @@ -14,7 +14,7 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.model.UserInfo import org.wordpress.android.util.AppLog -abstract class SupportViewModel( +abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, ) : ViewModel() { 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 f201191ce172..1f4daacc8947 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 @@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R -import org.wordpress.android.support.common.ui.SupportViewModel +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { @@ -63,13 +63,13 @@ class HESupportActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.navigationEvents.collect { event -> when (event) { - is SupportViewModel.NavigationEvent.NavigateToConversationDetail -> { + is ConversationsSupportViewModel.NavigationEvent.NavigateToConversationDetail -> { navController.navigate(ConversationScreen.Detail.name) } - SupportViewModel.NavigationEvent.NavigateToNewConversation -> { + ConversationsSupportViewModel.NavigationEvent.NavigateToNewConversation -> { navController.navigate(ConversationScreen.NewTicket.name) } - SupportViewModel.NavigationEvent.NavigateBack -> { + ConversationsSupportViewModel.NavigationEvent.NavigateBack -> { navController.navigateUp() } } @@ -94,8 +94,8 @@ class HESupportActivity : AppCompatActivity() { // Show snackbar when error occurs errorMessage?.let { errorType -> val message = when (errorType) { - SupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) - SupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) + ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) } scope.launch { snackbarHostState.showSnackbar( 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 b5199903c120..17806291ab46 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 @@ -2,16 +2,13 @@ package org.wordpress.android.support.he.ui import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.support.common.ui.SupportViewModel +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository @@ -23,7 +20,7 @@ class HESupportViewModel @Inject constructor( accountStore: AccountStore, private val heSupportRepository: HESupportRepository, appLogWrapper: AppLogWrapper, -) : SupportViewModel(accountStore, appLogWrapper) { +) : ConversationsSupportViewModel(accountStore, appLogWrapper) { private val _isSendingNewConversation = MutableStateFlow(false) val isSendingNewConversation: StateFlow = _isSendingNewConversation.asStateFlow() From 8c057bfabc7f085c9590adb0480c25aeaeb2d029 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 12:53:02 +0200 Subject: [PATCH 43/81] More refactor --- .../support/aibot/model/BotConversation.kt | 5 ++- .../support/aibot/ui/AIBotSupportActivity.kt | 4 +- .../support/aibot/ui/AIBotSupportViewModel.kt | 35 +++--------------- .../support/common/model/Conversation.kt | 5 +++ .../ui/ConversationsSupportViewModel.kt | 37 +++++++++++++++++-- .../support/he/model/SupportConversation.kt | 5 ++- .../he/ui/HEConversationsListScreen.kt | 2 - .../support/he/ui/HESupportViewModel.kt | 4 ++ .../repository/AIBotSupportRepositoryTest.kt | 10 ++--- .../aibot/ui/AIBotSupportViewModelTest.kt | 23 ++++++------ 10 files changed, 75 insertions(+), 55 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/common/model/Conversation.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotConversation.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotConversation.kt index f71d345523b4..a515a30a3aca 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotConversation.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotConversation.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.aibot.model +import org.wordpress.android.support.common.model.Conversation import java.util.Date data class BotConversation( @@ -8,4 +9,6 @@ data class BotConversation( val mostRecentMessageDate: Date, val lastMessage: String, val messages: List -) +): Conversation { + override fun getConversationId(): Long = id +} 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 51cdddebac03..c9312f184fec 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 @@ -122,11 +122,11 @@ class AIBotSupportActivity : AppCompatActivity() { conversations = viewModel.conversations, isLoading = isLoadingConversations, onConversationClick = { conversation -> - viewModel.onConversationSelected(conversation) + viewModel.onConversationClick(conversation) }, onBackClick = { finish() }, onCreateNewConversationClick = { - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() }, onRefresh = { viewModel.refreshConversations() 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 d40295fd6b69..d9bb74a79dd4 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 @@ -22,13 +22,9 @@ class AIBotSupportViewModel @Inject constructor( private val aiBotSupportRepository: AIBotSupportRepository, appLogWrapper: AppLogWrapper, ) : ConversationsSupportViewModel(accountStore, appLogWrapper) { - private val _canSendMessage = MutableStateFlow(true) val canSendMessage: StateFlow = _canSendMessage.asStateFlow() - private val _isLoadingConversation = MutableStateFlow(false) - val isLoadingConversation: StateFlow = _isLoadingConversation.asStateFlow() - private val _isBotTyping = MutableStateFlow(false) val isBotTyping: StateFlow = _isBotTyping.asStateFlow() @@ -38,32 +34,14 @@ class AIBotSupportViewModel @Inject constructor( override suspend fun getConversations() = aiBotSupportRepository.loadConversations() - @Suppress("TooGenericExceptionCaught") - fun onConversationSelected(conversation: BotConversation) { - viewModelScope.launch { - try { - _isLoadingConversation.value = true - _selectedConversation.value = conversation - _canSendMessage.value = true - val updatedConversation = aiBotSupportRepository.loadConversation(conversation.id) - if (updatedConversation != null) { - // Simulate clicking on the conversation - onConversationClick(updatedConversation) - } else { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation: " + - "error retrieving it from server") - } - } catch (throwable: Throwable) { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation: " + - "${throwable.message} - ${throwable.stackTraceToString()}") - } - _isLoadingConversation.value = false + override suspend fun getConversation(conversationId: Long): BotConversation? { + _canSendMessage.value = false + return aiBotSupportRepository.loadConversation(conversationId).also { + _canSendMessage.value = true } } - fun onNewConversationClicked() { + fun onNewConversationClick() { viewModelScope.launch { val now = Date() val botConversation = BotConversation( @@ -74,8 +52,7 @@ class AIBotSupportViewModel @Inject constructor( messages = listOf() ) _canSendMessage.value = true - // Simulate clicking on the conversation - onConversationClick(botConversation) + setNewConversation(botConversation) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/model/Conversation.kt b/WordPress/src/main/java/org/wordpress/android/support/common/model/Conversation.kt new file mode 100644 index 000000000000..03fcd4a0388b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/model/Conversation.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.support.common.model + +interface Conversation { + fun getConversationId(): Long +} 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 ae596838a392..d32188d91a95 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 @@ -1,5 +1,6 @@ package org.wordpress.android.support.common.ui +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -11,10 +12,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch 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.model.UserInfo import org.wordpress.android.util.AppLog -abstract class ConversationsSupportViewModel( +abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, ) : ViewModel() { @@ -30,6 +32,9 @@ abstract class ConversationsSupportViewModel( protected val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations.asStateFlow() + private val _isLoadingConversation = MutableStateFlow(false) + val isLoadingConversation: StateFlow = _isLoadingConversation.asStateFlow() + protected val _selectedConversation = MutableStateFlow(null) val selectedConversation: StateFlow = _selectedConversation.asStateFlow() @@ -111,17 +116,43 @@ abstract class ConversationsSupportViewModel( _errorMessage.value = null } + suspend fun setNewConversation(conversation: ConversationType) { + _selectedConversation.value = conversation + _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) + } + // Region navigation fun onConversationClick(conversation: ConversationType) { viewModelScope.launch { - _selectedConversation.value = conversation - _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) + try { + _isLoadingConversation.value = true + _selectedConversation.value = conversation + _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) + + val updatedConversation = getConversation(conversation.getConversationId()) + if (updatedConversation != null) { + // refresh selected conversation + _selectedConversation.value = updatedConversation + } else { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation: " + + "error retrieving it from server") + } + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } + _isLoadingConversation.value = false } } + abstract suspend fun getConversation(conversationId: Long): ConversationType? + fun onBackFromDetailClick() { viewModelScope.launch { + _selectedConversation.value = null _navigationEvents.emit(NavigationEvent.NavigateBack) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt index 38103bf3221e..dd81da57827e 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/SupportConversation.kt @@ -1,5 +1,6 @@ package org.wordpress.android.support.he.model +import org.wordpress.android.support.common.model.Conversation import java.util.Date data class SupportConversation( @@ -8,4 +9,6 @@ data class SupportConversation( val description: String, val lastMessageSentAt: Date, val messages: List -) +): Conversation { + override fun getConversationId(): Long = id +} 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 da2f8c447b04..1e0666530ebc 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 @@ -17,7 +17,6 @@ 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.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,7 +24,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.ui.text.style.TextAlign import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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 17806291ab46..7fd5e64906a9 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 @@ -65,6 +65,10 @@ class HESupportViewModel @Inject constructor( } } + override suspend fun getConversation(conversationId: Long): SupportConversation? { + TODO("Not yet implemented") + } + fun onAddMessageToConversation( message: String, attachments: List 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..c4128e946927 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 @@ -78,9 +78,9 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.loadConversations() assertThat(result).hasSize(2) - assertThat(result[0].id).isEqualTo(1L) + assertThat(result[0].getConversationId).isEqualTo(1L) assertThat(result[0].lastMessage).isEqualTo("First conversation") - assertThat(result[1].id).isEqualTo(2L) + assertThat(result[1].getConversationId).isEqualTo(2L) assertThat(result[1].lastMessage).isEqualTo("Second conversation") } @@ -112,7 +112,7 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.loadConversation(testChatId.toLong()) assertThat(result).isNotNull - assertThat(result?.id).isEqualTo(testChatId) + assertThat(result?.getConversationId).isEqualTo(testChatId) assertThat(result?.messages).hasSize(2) assertThat(result?.messages?.get(0)?.isWrittenByUser).isTrue assertThat(result?.messages?.get(0)?.text).isEqualTo("User message") @@ -184,7 +184,7 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.createNewConversation(testMessage) assertThat(result).isNotNull - assertThat(result?.id).isEqualTo(newChatId) + assertThat(result?.getConversationId).isEqualTo(newChatId) assertThat(result?.messages).hasSize(2) assertThat(result?.messages?.get(0)?.text).isEqualTo(testMessage) assertThat(result?.messages?.get(0)?.isWrittenByUser).isTrue @@ -239,7 +239,7 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.sendMessageToConversation(existingChatId.toLong(), newMessage) assertThat(result).isNotNull - assertThat(result?.id).isEqualTo(existingChatId) + assertThat(result?.getConversationId).isEqualTo(existingChatId) assertThat(result?.messages).hasSize(4) assertThat(result?.messages?.get(2)?.text).isEqualTo(newMessage) assertThat(result?.messages?.get(2)?.isWrittenByUser).isTrue 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 745e78867ebb..04c297fe7179 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,6 +1,5 @@ package org.wordpress.android.support.aibot.ui -import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -157,11 +156,11 @@ class AIBotSupportViewModelTest : BaseUnitTest() { @Test fun `onNewConversationClicked creates empty conversation`() = test { - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() val selectedConversation = viewModel.selectedConversation.value assertThat(selectedConversation).isNotNull - assertThat(selectedConversation?.id).isEqualTo(0L) + assertThat(selectedConversation?.getConversationId).isEqualTo(0L) assertThat(selectedConversation?.messages).isEmpty() assertThat(selectedConversation?.lastMessage).isEmpty() assertThat(viewModel.canSendMessage.value).isTrue @@ -178,7 +177,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { ) whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() viewModel.sendMessage(message) advanceUntilIdle() @@ -224,7 +223,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val newConversation = createTestConversation(id = 123L) whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() viewModel.sendMessage(message) advanceUntilIdle() @@ -237,7 +236,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val newConversation = createTestConversation(id = 123L) whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() assertThat(viewModel.canSendMessage.value).isTrue viewModel.sendMessage(message) @@ -252,7 +251,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val newConversation = createTestConversation(id = 123L) whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() viewModel.sendMessage(message) // Allow the optimistic update to complete @@ -269,7 +268,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val message = "Test message" whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(null) - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() viewModel.sendMessage(message) advanceUntilIdle() @@ -285,7 +284,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val exception = RuntimeException("Send failed") whenever(aiBotSupportRepository.createNewConversation(message)).thenThrow(exception) - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() viewModel.sendMessage(message) advanceUntilIdle() @@ -312,12 +311,12 @@ class AIBotSupportViewModelTest : BaseUnitTest() { viewModel.init(testAccessToken, testUserId) advanceUntilIdle() - viewModel.onNewConversationClicked() + viewModel.onNewConversationClick() viewModel.sendMessage(message) advanceUntilIdle() assertThat(viewModel.conversations.value).hasSize(3) - assertThat(viewModel.conversations.value.first().id).isEqualTo(999L) + assertThat(viewModel.conversations.value.first().getConversationId).isEqualTo(999L) } @Test @@ -352,7 +351,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { val updatedList = viewModel.conversations.value assertThat(updatedList).hasSize(2) - val updatedInList = updatedList.find { it.id == conversationId } + val updatedInList = updatedList.find { it.getConversationId == conversationId } assertThat(updatedInList?.lastMessage).isEqualTo("Bot response") } From a4ed792aebc0a607837be4c6c731d30eb7d7166f Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 13:08:40 +0200 Subject: [PATCH 44/81] Capitalise text fields --- .../support/aibot/ui/AIBotConversationDetailScreen.kt | 3 +++ .../wordpress/android/support/he/ui/HENewTicketScreen.kt | 8 ++++++-- .../android/support/he/ui/TicketMainContentView.kt | 5 ++++- 3 files changed, 13 insertions(+), 3 deletions(-) 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 69f090e3af02..d2a1caf4e729 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 @@ -42,8 +42,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +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 android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.text.style.TextAlign @@ -218,6 +220,7 @@ private fun ChatInputBar( modifier = Modifier.weight(1f), placeholder = { Text(stringResource(R.string.ai_bot_message_input_placeholder)) }, maxLines = 4, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) IconButton( 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 8505d5dd5139..0a4e113d4e6a 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 @@ -40,8 +40,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +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 org.wordpress.android.R import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons @@ -142,7 +144,8 @@ fun HENewTicketScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) }, - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) Spacer(modifier = Modifier.height(24.dp)) @@ -165,7 +168,8 @@ fun HENewTicketScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) }, - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) Spacer(modifier = Modifier.height(32.dp)) 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 e4c49748a673..96931e861bfa 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 @@ -24,8 +24,10 @@ 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.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -55,7 +57,8 @@ fun TicketMainContentView( modifier = Modifier .fillMaxWidth() .height(200.dp), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) Spacer(modifier = Modifier.height(24.dp)) From ccef4b767b847c6367d67f13ba242dc3c03dd82a Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 13:13:42 +0200 Subject: [PATCH 45/81] Updating rs library --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 07e2a0cb9e29..f85f11cbd821 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wiremock = '2.26.3' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-1a64cb921601fd34bfe6030919960676d45a19c0' +wordpress-rs = 'trunk-a0864c91b8dc3726b0ad43e22662c4415aca59ce' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1' From be6a5f2b0ec7d55a1d15bd02b17545f126756c4f Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 13:39:12 +0200 Subject: [PATCH 46/81] Loading conversation UX --- .../he/ui/HEConversationDetailScreen.kt | 22 +++++++++++++++---- .../support/he/ui/HESupportActivity.kt | 2 ++ .../support/he/ui/HESupportViewModel.kt | 5 ++--- 3 files changed, 22 insertions(+), 7 deletions(-) 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 328ccf07e0d1..b724addaed8f 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -54,6 +55,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @Composable fun HEConversationDetailScreen( conversation: SupportConversation, + isLoading: Boolean = false, onBackClick: () -> Unit ) { val listState = rememberLazyListState() @@ -78,14 +80,18 @@ fun HEConversationDetailScreen( ) } ) { contentPadding -> - LazyColumn( + Box( modifier = Modifier .fillMaxSize() .padding(contentPadding) - .padding(horizontal = 16.dp), - state = listState, - verticalArrangement = Arrangement.spacedBy(12.dp) ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { item { ConversationHeader( messageCount = conversation.messages.size, @@ -113,6 +119,13 @@ fun HEConversationDetailScreen( Spacer(modifier = Modifier.height(8.dp)) } } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } } if (showBottomSheet) { @@ -382,6 +395,7 @@ private fun HEConversationDetailScreenPreviewWordPressDark() { AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationDetailScreen( + isLoading = true, conversation = sampleConversation, onBackClick = { } ) 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 1f4daacc8947..98749f4cabff 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 @@ -134,9 +134,11 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.Detail.name) { val selectedConversation by viewModel.selectedConversation.collectAsState() + val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() selectedConversation?.let { conversation -> HEConversationDetailScreen( conversation = conversation, + isLoading = isLoadingConversation, onBackClick = { viewModel.onBackFromDetailClick() } ) } 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 7fd5e64906a9..c54e37066b96 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 @@ -65,9 +65,8 @@ class HESupportViewModel @Inject constructor( } } - override suspend fun getConversation(conversationId: Long): SupportConversation? { - TODO("Not yet implemented") - } + override suspend fun getConversation(conversationId: Long): SupportConversation? = + heSupportRepository.loadConversation(conversationId) fun onAddMessageToConversation( message: String, From f023bf8b5c74b1abc5a34b1d25b5a0cb3c1743d5 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 13:48:57 +0200 Subject: [PATCH 47/81] Style fix --- .../android/support/he/repository/HESupportRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7589caa0c434..73dff6ad6e14 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 @@ -189,6 +189,6 @@ class HESupportRepository @Inject constructor( is SupportMessageAuthor.User -> (this.author as SupportMessageAuthor.User).v1.displayName is SupportMessageAuthor.SupportAgent -> (this.author as SupportMessageAuthor.SupportAgent).v1.name }, - authorIsUser = this.author is SupportMessageAuthor.User + authorIsUser = this.authorIsCurrentUser ) } From d33e5127e5297a7f85f0cb0c6f8c38fddbf68d2a Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 14:11:40 +0200 Subject: [PATCH 48/81] Fixing scaffolds paddings --- .../aibot/ui/AIBotConversationDetailScreen.kt | 12 +++ .../aibot/ui/AIBotConversationsListScreen.kt | 17 +++ .../support/aibot/ui/AIBotSupportActivity.kt | 83 +++++++------- .../he/ui/HEConversationDetailScreen.kt | 12 +++ .../he/ui/HEConversationsListScreen.kt | 13 +++ .../support/he/ui/HENewTicketScreen.kt | 12 +++ .../support/he/ui/HESupportActivity.kt | 102 +++++++++--------- 7 files changed, 156 insertions(+), 95 deletions(-) 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 d2a1caf4e729..8cd4b6d20a1f 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 @@ -47,6 +47,8 @@ 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 @@ -59,6 +61,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationDetailScreen( + snackbarHostState: SnackbarHostState, conversation: BotConversation, isLoading: Boolean, isBotTyping: Boolean, @@ -85,6 +88,7 @@ fun ConversationDetailScreen( val resources = LocalResources.current Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { }, @@ -355,9 +359,11 @@ private fun TypingDot(delay: Int) { @Composable private fun ConversationDetailScreenPreview() { val sampleConversation = generateSampleBotConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { ConversationDetailScreen( + snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, isLoading = false, @@ -373,9 +379,11 @@ private fun ConversationDetailScreenPreview() { @Composable private fun ConversationDetailScreenPreviewDark() { val sampleConversation = generateSampleBotConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { ConversationDetailScreen( + snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, isLoading = false, @@ -391,9 +399,11 @@ private fun ConversationDetailScreenPreviewDark() { @Composable private fun ConversationDetailScreenWordPressPreview() { val sampleConversation = generateSampleBotConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { ConversationDetailScreen( + snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, isLoading = false, @@ -409,9 +419,11 @@ private fun ConversationDetailScreenWordPressPreview() { @Composable private fun ConversationDetailScreenPreviewWordPressDark() { val sampleConversation = generateSampleBotConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { ConversationDetailScreen( + snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, isLoading = false, 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 d52ff5048727..c82247d2c8f4 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 @@ -22,12 +22,15 @@ 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.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource @@ -47,6 +50,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationsListScreen( + snackbarHostState: SnackbarHostState, conversations: StateFlow>, isLoading: Boolean, onConversationClick: (BotConversation) -> Unit, @@ -55,6 +59,7 @@ fun ConversationsListScreen( onRefresh: () -> Unit, ) { Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text(stringResource(R.string.ai_bot_conversations_title)) }, @@ -186,9 +191,11 @@ private fun ConversationCard( @Composable private fun ConversationsScreenPreview() { val sampleConversations = MutableStateFlow(generateSampleBotConversations()) + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { ConversationsListScreen( + snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = false, onConversationClick = { }, @@ -203,9 +210,11 @@ private fun ConversationsScreenPreview() { @Composable private fun ConversationsScreenPreviewDark() { val sampleConversations = MutableStateFlow(generateSampleBotConversations()) + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { ConversationsListScreen( + snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = false, onConversationClick = { }, @@ -220,9 +229,11 @@ private fun ConversationsScreenPreviewDark() { @Composable private fun ConversationsScreenWordPressPreview() { val sampleConversations = MutableStateFlow(generateSampleBotConversations()) + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { ConversationsListScreen( + snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = true, onConversationClick = { }, @@ -237,9 +248,11 @@ private fun ConversationsScreenWordPressPreview() { @Composable private fun ConversationsScreenPreviewWordPressDark() { val sampleConversations = MutableStateFlow(generateSampleBotConversations()) + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { ConversationsListScreen( + snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = true, onConversationClick = { }, @@ -254,9 +267,11 @@ private fun ConversationsScreenPreviewWordPressDark() { @Composable private fun EmptyConversationsScreenPreview() { val emptyConversations = MutableStateFlow(emptyList()) + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { ConversationsListScreen( + snackbarHostState = snackbarHostState, conversations = emptyConversations.asStateFlow(), isLoading = false, onConversationClick = { }, @@ -271,9 +286,11 @@ private fun EmptyConversationsScreenPreview() { @Composable private fun EmptyConversationsScreenPreviewDark() { val emptyConversations = MutableStateFlow(emptyList()) + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { ConversationsListScreen( + snackbarHostState = snackbarHostState, conversations = emptyConversations.asStateFlow(), isLoading = false, onConversationClick = { }, 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 c9312f184fec..98f2db0685d4 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 @@ -108,52 +108,49 @@ class AIBotSupportActivity : AppCompatActivity() { } AppThemeM3 { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { paddingValues -> - NavHost( - navController = navController, - startDestination = ConversationScreen.List.name, - modifier = Modifier.padding(paddingValues) - ) { - composable(route = ConversationScreen.List.name) { - val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() - ConversationsListScreen( - conversations = viewModel.conversations, - isLoading = isLoadingConversations, - onConversationClick = { conversation -> - viewModel.onConversationClick(conversation) - }, - onBackClick = { finish() }, - onCreateNewConversationClick = { - viewModel.onNewConversationClick() - }, - onRefresh = { - viewModel.refreshConversations() + NavHost( + navController = navController, + startDestination = ConversationScreen.List.name, + ) { + composable(route = ConversationScreen.List.name) { + val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + ConversationsListScreen( + snackbarHostState = snackbarHostState, + conversations = viewModel.conversations, + isLoading = isLoadingConversations, + onConversationClick = { conversation -> + viewModel.onConversationClick(conversation) + }, + onBackClick = { finish() }, + onCreateNewConversationClick = { + viewModel.onNewConversationClick() + }, + onRefresh = { + viewModel.refreshConversations() + }, + ) + } + + composable(route = ConversationScreen.Detail.name) { + val selectedConversation by viewModel.selectedConversation.collectAsState() + val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() + val isBotTyping by viewModel.isBotTyping.collectAsState() + val canSendMessage by viewModel.canSendMessage.collectAsState() + val userInfo by viewModel.userInfo.collectAsState() + selectedConversation?.let { conversation -> + ConversationDetailScreen( + snackbarHostState = snackbarHostState, + userName = userInfo.userName, + conversation = conversation, + isLoading = isLoadingConversation, + isBotTyping = isBotTyping, + canSendMessage = canSendMessage, + onBackClick = { viewModel.onBackFromDetailClick() }, + onSendMessage = { text -> + viewModel.sendMessage(text) } ) } - - composable(route = ConversationScreen.Detail.name) { - val selectedConversation by viewModel.selectedConversation.collectAsState() - val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() - val isBotTyping by viewModel.isBotTyping.collectAsState() - val canSendMessage by viewModel.canSendMessage.collectAsState() - val userInfo by viewModel.userInfo.collectAsState() - selectedConversation?.let { conversation -> - ConversationDetailScreen( - userName = userInfo.userName, - conversation = conversation, - isLoading = isLoadingConversation, - isBotTyping = isBotTyping, - canSendMessage = canSendMessage, - onBackClick = { viewModel.onBackFromDetailClick() }, - onSendMessage = { text -> - viewModel.sendMessage(text) - } - ) - } - } } } } 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 b724addaed8f..dd28a17ee2f7 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 @@ -25,6 +25,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState @@ -54,6 +56,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun HEConversationDetailScreen( + snackbarHostState: SnackbarHostState, conversation: SupportConversation, isLoading: Boolean = false, onBackClick: () -> Unit @@ -65,6 +68,7 @@ fun HEConversationDetailScreen( val resources = LocalResources.current Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { MainTopAppBar( title = "", @@ -353,9 +357,11 @@ private fun ReplyBottomSheet( @Composable private fun HEConversationDetailScreenPreview() { val sampleConversation = generateSampleHESupportConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { HEConversationDetailScreen( + snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { } ) @@ -366,9 +372,11 @@ private fun HEConversationDetailScreenPreview() { @Composable private fun HEConversationDetailScreenPreviewDark() { val sampleConversation = generateSampleHESupportConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { HEConversationDetailScreen( + snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { } ) @@ -379,9 +387,11 @@ private fun HEConversationDetailScreenPreviewDark() { @Composable private fun HEConversationDetailScreenWordPressPreview() { val sampleConversation = generateSampleHESupportConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HEConversationDetailScreen( + snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { } ) @@ -392,9 +402,11 @@ private fun HEConversationDetailScreenWordPressPreview() { @Composable private fun HEConversationDetailScreenPreviewWordPressDark() { val sampleConversation = generateSampleHESupportConversations()[0] + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationDetailScreen( + snackbarHostState = snackbarHostState, isLoading = true, conversation = sampleConversation, onBackClick = { } 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 1e0666530ebc..3f82fc96aa9f 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 @@ -22,11 +22,14 @@ 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 import androidx.compose.ui.platform.LocalResources @@ -51,6 +54,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun HEConversationsListScreen( + snackbarHostState: SnackbarHostState, conversations: StateFlow>, isLoadingConversations: StateFlow, onConversationClick: (SupportConversation) -> Unit, @@ -59,6 +63,7 @@ fun HEConversationsListScreen( onRefresh: () -> Unit ) { Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { MainTopAppBar( title = stringResource(R.string.he_support_conversations_title), @@ -213,9 +218,11 @@ private fun ConversationCard( 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(), onConversationClick = { }, @@ -231,9 +238,11 @@ private fun ConversationsScreenPreview() { 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(), onConversationClick = { }, @@ -249,9 +258,11 @@ private fun ConversationsScreenPreviewDark() { 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(), onConversationClick = { }, @@ -267,9 +278,11 @@ private fun ConversationsScreenWordPressPreview() { 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(), onConversationClick = { }, 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 0a4e113d4e6a..49bf0856bb3d 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 @@ -44,6 +44,8 @@ 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.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import org.wordpress.android.R import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons @@ -53,6 +55,7 @@ import org.wordpress.android.ui.dataview.compose.RemoteImage @OptIn(ExperimentalMaterial3Api::class) @Composable fun HENewTicketScreen( + snackbarHostState: SnackbarHostState, onBackClick: () -> Unit, onSubmit: ( category: SupportCategory, @@ -72,6 +75,7 @@ fun HENewTicketScreen( var includeAppLogs by remember { mutableStateOf(false) } Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { MainTopAppBar( title = stringResource(R.string.he_support_contact_support_title), @@ -364,8 +368,10 @@ private fun CategoryOption( @Preview(showBackground = true, name = "HE New Ticket Screen") @Composable private fun HENewTicketScreenPreview() { + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { HENewTicketScreen( + snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, userName = "Test user", @@ -378,8 +384,10 @@ private fun HENewTicketScreenPreview() { @Preview(showBackground = true, name = "HE New Ticket Screen - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun HENewTicketScreenPreviewDark() { + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { HENewTicketScreen( + snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, userName = "Test user", @@ -392,8 +400,10 @@ private fun HENewTicketScreenPreviewDark() { @Preview(showBackground = true, name = "HE New Ticket Screen - WordPress") @Composable private fun HENewTicketScreenWordPressPreview() { + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HENewTicketScreen( + snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, userName = "Test user", @@ -406,8 +416,10 @@ private fun HENewTicketScreenWordPressPreview() { @Preview(showBackground = true, name = "HE New Ticket Screen - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) @Composable private fun HENewTicketScreenPreviewWordPressDark() { + val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HENewTicketScreen( + snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, userName = "Test user", 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 98749f4cabff..292e3c926aa7 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 @@ -107,63 +107,61 @@ class HESupportActivity : AppCompatActivity() { } AppThemeM3 { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { paddingValues -> - NavHost( - navController = navController, - startDestination = ConversationScreen.List.name, - modifier = Modifier.padding(paddingValues) - ) { - composable(route = ConversationScreen.List.name) { - HEConversationsListScreen( - conversations = viewModel.conversations, - isLoadingConversations = viewModel.isLoadingConversations, - onConversationClick = { conversation -> - viewModel.onConversationClick(conversation) - }, - onBackClick = { finish() }, - onCreateNewConversationClick = { - viewModel.onCreateNewConversationClick() - }, - onRefresh = { - viewModel.refreshConversations() - } - ) - } - - composable(route = ConversationScreen.Detail.name) { - val selectedConversation by viewModel.selectedConversation.collectAsState() - val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() - selectedConversation?.let { conversation -> - HEConversationDetailScreen( - conversation = conversation, - isLoading = isLoadingConversation, - onBackClick = { viewModel.onBackFromDetailClick() } - ) + NavHost( + navController = navController, + startDestination = ConversationScreen.List.name, + ) { + composable(route = ConversationScreen.List.name) { + HEConversationsListScreen( + snackbarHostState = snackbarHostState, + conversations = viewModel.conversations, + isLoadingConversations = viewModel.isLoadingConversations, + onConversationClick = { conversation -> + viewModel.onConversationClick(conversation) + }, + onBackClick = { finish() }, + onCreateNewConversationClick = { + viewModel.onCreateNewConversationClick() + }, + onRefresh = { + viewModel.refreshConversations() } - } + ) + } - composable(route = ConversationScreen.NewTicket.name) { - val userInfo by viewModel.userInfo.collectAsState() - val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() - HENewTicketScreen( - onBackClick = { viewModel.onBackFromDetailClick() }, - onSubmit = { category, subject, messageText, siteAddress -> - viewModel.onSendNewConversation( - subject = subject, - message = messageText, - tags = listOf(category.name), - attachments = listOf() - ) - }, - userName = userInfo.userName, - userEmail = userInfo.userEmail, - userAvatarUrl = userInfo.avatarUrl, - isSendingNewConversation = isSendingNewConversation + composable(route = ConversationScreen.Detail.name) { + val selectedConversation by viewModel.selectedConversation.collectAsState() + val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() + selectedConversation?.let { conversation -> + HEConversationDetailScreen( + snackbarHostState = snackbarHostState, + conversation = conversation, + isLoading = isLoadingConversation, + onBackClick = { viewModel.onBackFromDetailClick() } ) } } + + composable(route = ConversationScreen.NewTicket.name) { + val userInfo by viewModel.userInfo.collectAsState() + val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() + HENewTicketScreen( + snackbarHostState = snackbarHostState, + onBackClick = { viewModel.onBackFromDetailClick() }, + onSubmit = { category, subject, messageText, siteAddress -> + viewModel.onSendNewConversation( + subject = subject, + message = messageText, + tags = listOf(category.name), + attachments = listOf() + ) + }, + userName = userInfo.userName, + userEmail = userInfo.userEmail, + userAvatarUrl = userInfo.avatarUrl, + isSendingNewConversation = isSendingNewConversation + ) + } } } } From ca5af7a324a26a280e6c01bb861cea23e3d67ff3 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 14:45:25 +0200 Subject: [PATCH 49/81] userID fix --- .../wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d9bb74a79dd4..4d0423d4f66b 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 @@ -29,7 +29,7 @@ class AIBotSupportViewModel @Inject constructor( val isBotTyping: StateFlow = _isBotTyping.asStateFlow() override fun initRepository(accessToken: String) { - aiBotSupportRepository.init(accessToken, accountStore.account.id.toLong()) + aiBotSupportRepository.init(accessToken, accountStore.account.userId) } override suspend fun getConversations() = aiBotSupportRepository.loadConversations() From 972641d7e708f46a8bb8e714ca697ea5e0c6a60d Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 15:02:58 +0200 Subject: [PATCH 50/81] Fixing the padding problem in bot chat when the keyboard is opened --- WordPress/src/main/AndroidManifest.xml | 3 ++- .../android/support/aibot/ui/AIBotConversationDetailScreen.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index db7bb513ede3..657faf716cee 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -438,7 +438,8 @@ + android:label="@string/ai_bot_conversations_title" + android:windowSoftInputMode="adjustResize"/> Date: Wed, 22 Oct 2025 15:06:44 +0200 Subject: [PATCH 51/81] Apply padding to create ticket screen when the keyboard is opened --- WordPress/src/main/AndroidManifest.xml | 3 ++- .../org/wordpress/android/support/he/ui/HENewTicketScreen.kt | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 657faf716cee..44c418a3fcb8 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -447,7 +447,8 @@ + android:label="@string/support_screen_title" + android:windowSoftInputMode="adjustResize"/> Date: Wed, 22 Oct 2025 15:17:38 +0200 Subject: [PATCH 52/81] Fixing scroll state in reply bottomsheet --- .../android/support/he/ui/HEConversationDetailScreen.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 dd28a17ee2f7..a6145a49c6b8 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 @@ -10,12 +10,15 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material3.Button @@ -301,6 +304,7 @@ private fun ReplyBottomSheet( ) { var messageText by remember { mutableStateOf("") } var includeAppLogs by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() ModalBottomSheet( onDismissRequest = onDismiss, @@ -309,6 +313,8 @@ private fun ReplyBottomSheet( Column( modifier = Modifier .fillMaxWidth() + .imePadding() + .verticalScroll(scrollState) .padding(horizontal = 16.dp) .padding(bottom = 32.dp) ) { From c2baa18cd99d5fec6eec09b25923635f75baac9b Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 15:48:24 +0200 Subject: [PATCH 53/81] Adding tests for the new common viewmodel --- .../ui/ConversationsSupportViewModel.kt | 1 - .../repository/AIBotSupportRepositoryTest.kt | 10 +- .../ui/ConversationsSupportViewModelTest.kt | 406 ++++++++++++++++++ .../support/main/ui/SupportViewModelTest.kt | 5 - 4 files changed, 411 insertions(+), 11 deletions(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt 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 d32188d91a95..a1856402920f 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 @@ -1,6 +1,5 @@ package org.wordpress.android.support.common.ui -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow 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 c4128e946927..bacfe7338571 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 @@ -78,9 +78,9 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.loadConversations() assertThat(result).hasSize(2) - assertThat(result[0].getConversationId).isEqualTo(1L) + assertThat(result[0].id).isEqualTo(1L) assertThat(result[0].lastMessage).isEqualTo("First conversation") - assertThat(result[1].getConversationId).isEqualTo(2L) + assertThat(result[1].id).isEqualTo(2L) assertThat(result[1].lastMessage).isEqualTo("Second conversation") } @@ -112,7 +112,7 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.loadConversation(testChatId.toLong()) assertThat(result).isNotNull - assertThat(result?.getConversationId).isEqualTo(testChatId) + 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") @@ -184,7 +184,7 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.createNewConversation(testMessage) assertThat(result).isNotNull - assertThat(result?.getConversationId).isEqualTo(newChatId) + assertThat(result?.id).isEqualTo(newChatId) assertThat(result?.messages).hasSize(2) assertThat(result?.messages?.get(0)?.text).isEqualTo(testMessage) assertThat(result?.messages?.get(0)?.isWrittenByUser).isTrue @@ -239,7 +239,7 @@ class AIBotSupportRepositoryTest : BaseUnitTest() { val result = repository.sendMessageToConversation(existingChatId.toLong(), newMessage) assertThat(result).isNotNull - assertThat(result?.getConversationId).isEqualTo(existingChatId) + assertThat(result?.id).isEqualTo(existingChatId) assertThat(result?.messages).hasSize(4) assertThat(result?.messages?.get(2)?.text).isEqualTo(newMessage) assertThat(result?.messages?.get(2)?.isWrittenByUser).isTrue 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 new file mode 100644 index 000000000000..10b522fe9c97 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt @@ -0,0 +1,406 @@ +package org.wordpress.android.support.common.ui + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +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 + +@ExperimentalCoroutinesApi +class ConversationsSupportViewModelTest : BaseUnitTest() { + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var viewModel: TestConversationsSupportViewModel + + private val testAccessToken = "test_access_token" + private val testUserName = "Test User" + private val testUserEmail = "test@example.com" + private val testAvatarUrl = "https://example.com/avatar.jpg" + + @Before + fun setUp() { + val accountModel = AccountModel().apply { + displayName = testUserName + userName = "testuser" + email = testUserEmail + avatarUrl = testAvatarUrl + } + whenever(accountStore.account).thenReturn(accountModel) + whenever(accountStore.hasAccessToken()).thenReturn(true) + whenever(accountStore.accessToken).thenReturn(testAccessToken) + + viewModel = TestConversationsSupportViewModel( + accountStore = accountStore, + appLogWrapper = appLogWrapper + ) + } + + // Init Tests + + @Test + fun `init successfully initializes repository and loads conversations`() = test { + val testConversations = createTestConversations() + viewModel.setConversationsToReturn(testConversations) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.initRepositoryCalled).isTrue + assertThat(viewModel.conversations.value).isEqualTo(testConversations) + assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.errorMessage.value).isNull() + } + + @Test + fun `init loads user info correctly`() = test { + viewModel.init() + advanceUntilIdle() + + val userInfo = viewModel.userInfo.value + assertThat(userInfo.accessToken).isEqualTo(testAccessToken) + assertThat(userInfo.userName).isEqualTo(testUserName) + assertThat(userInfo.userEmail).isEqualTo(testUserEmail) + assertThat(userInfo.avatarUrl).isEqualTo(testAvatarUrl) + } + + @Test + fun `init uses userName when displayName is empty`() = test { + val accountModel = AccountModel().apply { + displayName = "" + userName = "fallbackuser" + email = testUserEmail + } + whenever(accountStore.account).thenReturn(accountModel) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.userInfo.value.userName).isEqualTo("fallbackuser") + } + + @Test + fun `init sets avatarUrl to null when empty`() = test { + val accountModel = AccountModel().apply { + displayName = testUserName + userName = "testuser" + email = testUserEmail + avatarUrl = "" + } + whenever(accountStore.account).thenReturn(accountModel) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.userInfo.value.avatarUrl).isNull() + } + + @Test + fun `init sets FORBIDDEN error when access token is null`() = test { + whenever(accountStore.hasAccessToken()).thenReturn(false) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.FORBIDDEN) + assertThat(viewModel.initRepositoryCalled).isFalse + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `init sets GENERAL error when initialization throws exception`() = test { + viewModel.setShouldThrowOnInit(true) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `init sets GENERAL error when loading conversations fails`() = test { + viewModel.setShouldThrowOnGetConversations(true) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isLoadingConversations.value).isFalse + verify(appLogWrapper).e(any(), any()) + } + + // Refresh Conversations Tests + + @Test + fun `refreshConversations reloads conversations successfully`() = test { + val initialConversations = createTestConversations(count = 2) + val updatedConversations = createTestConversations(count = 3) + + viewModel.setConversationsToReturn(initialConversations) + viewModel.init() + advanceUntilIdle() + + viewModel.setConversationsToReturn(updatedConversations) + viewModel.refreshConversations() + advanceUntilIdle() + + assertThat(viewModel.conversations.value).isEqualTo(updatedConversations) + assertThat(viewModel.isLoadingConversations.value).isFalse + } + + @Test + fun `refreshConversations handles error gracefully`() = test { + viewModel.init() + advanceUntilIdle() + + viewModel.setShouldThrowOnGetConversations(true) + viewModel.refreshConversations() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isLoadingConversations.value).isFalse + } + + // Clear Error Tests + + @Test + fun `clearError clears the error message`() = test { + viewModel.setShouldThrowOnGetConversations(true) + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isNotNull + + viewModel.clearError() + + assertThat(viewModel.errorMessage.value).isNull() + } + + // Navigation Tests + + @Test + fun `onConversationClick emits NavigateToConversationDetail event`() = test { + val conversation = createTestConversation(1) + viewModel.setConversationToReturn(conversation) + + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(emittedEvent).isEqualTo(ConversationsSupportViewModel.NavigationEvent.NavigateToConversationDetail) + job.cancel() + } + + @Test + fun `onConversationClick sets selected conversation`() = test { + val conversation = createTestConversation(1) + viewModel.setConversationToReturn(conversation) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.selectedConversation.value).isEqualTo(conversation) + } + + @Test + fun `onConversationClick sets loading state to false after loading`() = test { + val conversation = createTestConversation(1) + viewModel.setConversationToReturn(conversation) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.isLoadingConversation.value).isFalse + } + + @Test + fun `onConversationClick refreshes conversation with updated data`() = test { + val initialConversation = createTestConversation(1) + val updatedConversation = createTestConversation(1) + viewModel.setConversationToReturn(updatedConversation) + + viewModel.onConversationClick(initialConversation) + advanceUntilIdle() + + assertThat(viewModel.selectedConversation.value).isEqualTo(updatedConversation) + } + + @Test + fun `onConversationClick sets error when getConversation returns null`() = test { + val conversation = createTestConversation(1) + viewModel.setConversationToReturn(null) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `onConversationClick sets error when getConversation throws exception`() = test { + val conversation = createTestConversation(1) + viewModel.setShouldThrowOnGetConversation(true) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isLoadingConversation.value).isFalse + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `onBackFromDetailClick clears selected conversation`() = test { + val conversation = createTestConversation(1) + viewModel.setConversationToReturn(conversation) + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.selectedConversation.value).isNotNull + + viewModel.onBackFromDetailClick() + advanceUntilIdle() + + assertThat(viewModel.selectedConversation.value).isNull() + } + + @Test + fun `onBackFromDetailClick emits NavigateBack event`() = test { + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onBackFromDetailClick() + advanceUntilIdle() + + assertThat(emittedEvent).isEqualTo(ConversationsSupportViewModel.NavigationEvent.NavigateBack) + job.cancel() + } + + @Test + fun `onCreateNewConversationClick emits NavigateToNewConversation event`() = test { + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onCreateNewConversationClick() + advanceUntilIdle() + + assertThat(emittedEvent).isEqualTo(ConversationsSupportViewModel.NavigationEvent.NavigateToNewConversation) + job.cancel() + } + + @Test + fun `setNewConversation sets selected conversation and emits navigation event`() = test { + val conversation = createTestConversation(1) + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.setNewConversation(conversation) + advanceUntilIdle() + + assertThat(emittedEvent).isEqualTo(ConversationsSupportViewModel.NavigationEvent.NavigateToConversationDetail) + assertThat(viewModel.selectedConversation.value).isEqualTo(conversation) + job.cancel() + } + + // Helper Methods + + private fun createTestConversations(count: Int = 2): List { + return (1..count).map { createTestConversation(it.toLong()) } + } + + private fun createTestConversation(id: Long): TestConversation { + return TestConversation(id) + } + // Test Implementation Classes + + private data class TestConversation(val id: Long) : Conversation { + override fun getConversationId(): Long = id + } + + private class TestConversationsSupportViewModel( + accountStore: AccountStore, + appLogWrapper: AppLogWrapper + ) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + + var initRepositoryCalled = false + private var shouldThrowOnInit = false + private var shouldThrowOnGetConversations = false + private var shouldThrowOnGetConversation = false + private var conversationsToReturn: List = emptyList() + private var conversationToReturn: TestConversation? = null + + fun setShouldThrowOnInit(shouldThrow: Boolean) { + shouldThrowOnInit = shouldThrow + } + + fun setShouldThrowOnGetConversations(shouldThrow: Boolean) { + shouldThrowOnGetConversations = shouldThrow + } + + fun setShouldThrowOnGetConversation(shouldThrow: Boolean) { + shouldThrowOnGetConversation = shouldThrow + } + + fun setConversationsToReturn(conversations: List) { + conversationsToReturn = conversations + } + + fun setConversationToReturn(conversation: TestConversation?) { + conversationToReturn = conversation + } + + override fun initRepository(accessToken: String) { + if (shouldThrowOnInit) { + throw RuntimeException("Init failed") + } + initRepositoryCalled = true + } + + override suspend fun getConversations(): List { + if (shouldThrowOnGetConversations) { + throw RuntimeException("Get conversations failed") + } + return conversationsToReturn + } + + override suspend fun getConversation(conversationId: Long): TestConversation? { + if (shouldThrowOnGetConversation) { + throw RuntimeException("Get conversation failed") + } + return conversationToReturn + } + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt index 9d6ea10957e1..f256a04cecf9 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt @@ -186,9 +186,6 @@ class SupportViewModelTest : BaseUnitTest() { // Then val event = awaitItem() assertThat(event).isInstanceOf(SupportViewModel.NavigationEvent.NavigateToAskTheBots::class.java) - val navigateEvent = event as SupportViewModel.NavigationEvent.NavigateToAskTheBots - assertThat(navigateEvent.accessToken).isEqualTo(accessToken) - assertThat(navigateEvent.userName).isEqualTo(displayName) } } @@ -211,8 +208,6 @@ class SupportViewModelTest : BaseUnitTest() { // Then val event = awaitItem() assertThat(event).isInstanceOf(SupportViewModel.NavigationEvent.NavigateToAskTheBots::class.java) - val navigateEvent = event as SupportViewModel.NavigationEvent.NavigateToAskTheBots - assertThat(navigateEvent.userName).isEqualTo(userName) } } From 6aa8de13c0775d05f1cc85fb37922946279fb2fe Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 16:00:42 +0200 Subject: [PATCH 54/81] Fixing AIBotSupportViewModel tests --- .../aibot/ui/AIBotSupportViewModelTest.kt | 410 +++++++++--------- 1 file changed, 215 insertions(+), 195 deletions(-) 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 04c297fe7179..5f0c80eae315 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 @@ -10,16 +10,23 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest +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.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 java.util.Date @ExperimentalCoroutinesApi class AIBotSupportViewModelTest : BaseUnitTest() { + @Mock + private lateinit var accountStore: AccountStore + @Mock private lateinit var aiBotSupportRepository: AIBotSupportRepository + @Mock private lateinit var appLogWrapper: AppLogWrapper @@ -27,334 +34,338 @@ class AIBotSupportViewModelTest : BaseUnitTest() { private val testAccessToken = "test_access_token" private val testUserId = 12345L + private val testUserName = "Test User" + private val testUserEmail = "test@example.com" + private val testAvatarUrl = "https://example.com/avatar.jpg" @Before fun setUp() { + val accountModel = AccountModel().apply { + displayName = testUserName + userName = "testuser" + email = testUserEmail + avatarUrl = testAvatarUrl + userId = testUserId + } + whenever(accountStore.account).thenReturn(accountModel) + whenever(accountStore.hasAccessToken()).thenReturn(true) + whenever(accountStore.accessToken).thenReturn(testAccessToken) + viewModel = AIBotSupportViewModel( + accountStore = accountStore, aiBotSupportRepository = aiBotSupportRepository, appLogWrapper = appLogWrapper ) } - @Test - fun `init successfully loads conversations`() = test { - val testConversations = createTestConversations() - whenever(aiBotSupportRepository.loadConversations()).thenReturn(testConversations) - - viewModel.init(testAccessToken, testUserId) - advanceUntilIdle() + // region StateFlow initial values tests - verify(aiBotSupportRepository).init(testAccessToken, testUserId) - verify(aiBotSupportRepository).loadConversations() - assertThat(viewModel.conversations.value).isEqualTo(testConversations) - assertThat(viewModel.isLoadingConversations.value).isFalse + @Test + fun `canSendMessage is true initially`() { + assertThat(viewModel.canSendMessage.value).isTrue } @Test - fun `init sets error when repository init fails`() = test { - val exception = RuntimeException("Init failed") - whenever(aiBotSupportRepository.init(any(), any())).thenThrow(exception) + fun `isBotTyping is false initially`() { + assertThat(viewModel.isBotTyping.value).isFalse + } - viewModel.init(testAccessToken, testUserId) - advanceUntilIdle() + // endregion - assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) - verify(appLogWrapper).e(any(), any()) - } + // region getConversation() override tests @Test - fun `init sets error when loading conversations fails`() = test { - val exception = RuntimeException("Load failed") - whenever(aiBotSupportRepository.loadConversations()).thenThrow(exception) + fun `getConversation resets canSendMessage to true even when repository returns null`() = test { + val conversation = createTestConversation(1) + whenever(aiBotSupportRepository.loadConversation(1L)).thenReturn(null) - viewModel.init(testAccessToken, testUserId) + viewModel.onConversationClick(conversation) advanceUntilIdle() - assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isLoadingConversations.value).isFalse - verify(appLogWrapper).e(any(), any()) + assertThat(viewModel.canSendMessage.value).isTrue } - @Test - fun `refreshConversations reloads conversations successfully`() = test { - val initialConversations = createTestConversations() - val updatedConversations = createTestConversations(count = 3) + // endregion - whenever(aiBotSupportRepository.loadConversations()) - .thenReturn(initialConversations) - .thenReturn(updatedConversations) + // region onNewConversationClick() tests - viewModel.init(testAccessToken, testUserId) + @Test + fun `onNewConversationClick creates new conversation with empty messages`() = test { + viewModel.onNewConversationClick() advanceUntilIdle() - viewModel.refreshConversations() + val selectedConversation = viewModel.selectedConversation.value + assertThat(selectedConversation).isNotNull + assertThat(selectedConversation?.id).isEqualTo(0) + assertThat(selectedConversation?.messages).isEmpty() + assertThat(selectedConversation?.lastMessage).isEmpty() + } + + @Test + fun `onNewConversationClick sets canSendMessage to true`() = test { + viewModel.onNewConversationClick() advanceUntilIdle() - assertThat(viewModel.conversations.value).isEqualTo(updatedConversations) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.canSendMessage.value).isTrue } + // endregion + + // region sendMessage() tests + @Test - fun `clearError clears the error message`() = test { - whenever(aiBotSupportRepository.loadConversations()).thenThrow(RuntimeException("Error")) + fun `sendMessage adds user message to conversation immediately`() = test { + whenever(aiBotSupportRepository.createNewConversation(any())).thenReturn( + createTestConversation(1).copy(messages = listOf(createTestMessage(1, "Bot response", false))) + ) - viewModel.init(testAccessToken, testUserId) + viewModel.onNewConversationClick() advanceUntilIdle() - assertThat(viewModel.errorMessage.value).isNotNull - - viewModel.clearError() + viewModel.sendMessage("Hello bot") + advanceUntilIdle() - assertThat(viewModel.errorMessage.value).isNull() + 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 } @Test - fun `onConversationSelected loads conversation details successfully`() = test { - val conversation = createTestConversation(id = 1L) - val detailedConversation = conversation.copy( - messages = listOf( - BotMessage(1L, "User message", Date(), true), - BotMessage(2L, "Bot response", Date(), false) - ) + fun `sendMessage sets canSendMessage to false during request then true after`() = test { + whenever(aiBotSupportRepository.createNewConversation(any())).thenReturn( + createTestConversation(1) ) - whenever(aiBotSupportRepository.loadConversation(1L)).thenReturn(detailedConversation) - viewModel.onConversationSelected(conversation) + viewModel.onNewConversationClick() advanceUntilIdle() - assertThat(viewModel.selectedConversation.value).isEqualTo(detailedConversation) assertThat(viewModel.canSendMessage.value).isTrue - assertThat(viewModel.isLoadingConversation.value).isFalse - } - - @Test - fun `onConversationSelected sets error when repository returns null`() = test { - val conversation = createTestConversation(id = 1L) - whenever(aiBotSupportRepository.loadConversation(1L)).thenReturn(null) - viewModel.onConversationSelected(conversation) + viewModel.sendMessage("Hello bot") advanceUntilIdle() - assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isLoadingConversation.value).isFalse - verify(appLogWrapper).e(any(), any()) + assertThat(viewModel.canSendMessage.value).isTrue } @Test - fun `onConversationSelected sets error when repository throws exception`() = test { - val conversation = createTestConversation(id = 1L) - val exception = RuntimeException("Load failed") - whenever(aiBotSupportRepository.loadConversation(1L)).thenThrow(exception) + fun `sendMessage creates new conversation for conversation with id 0`() = test { + val botResponse = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "Bot response", false)) + ) + whenever(aiBotSupportRepository.createNewConversation("Hello bot")).thenReturn(botResponse) - viewModel.onConversationSelected(conversation) + viewModel.onNewConversationClick() advanceUntilIdle() - assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isLoadingConversation.value).isFalse - verify(appLogWrapper).e(any(), any()) + viewModel.sendMessage("Hello bot") + advanceUntilIdle() + + verify(aiBotSupportRepository).createNewConversation("Hello bot") + assertThat(viewModel.selectedConversation.value?.id).isEqualTo(1) } @Test - fun `onNewConversationClicked creates empty conversation`() = test { - viewModel.onNewConversationClick() + fun `sendMessage sends to existing conversation when id is not 0`() = test { + val existingConversation = createTestConversation(5) + val botResponse = createTestConversation(5).copy( + messages = listOf(createTestMessage(1, "Bot response", false)) + ) + whenever(aiBotSupportRepository.loadConversation(5L)).thenReturn(existingConversation) + whenever(aiBotSupportRepository.sendMessageToConversation(eq(5L), eq("Hello again"))) + .thenReturn(botResponse) - val selectedConversation = viewModel.selectedConversation.value - assertThat(selectedConversation).isNotNull - assertThat(selectedConversation?.getConversationId).isEqualTo(0L) - assertThat(selectedConversation?.messages).isEmpty() - assertThat(selectedConversation?.lastMessage).isEmpty() - assertThat(viewModel.canSendMessage.value).isTrue + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.sendMessage("Hello again") + advanceUntilIdle() + + verify(aiBotSupportRepository).sendMessageToConversation(5L, "Hello again") } @Test - fun `sendMessage creates new conversation when id is 0`() = test { - val message = "Hello, I need help" - val newConversation = createTestConversation(id = 123L).copy( - messages = listOf( - BotMessage(1L, message, Date(), true), - BotMessage(2L, "Bot response", Date(), false) - ) + fun `sendMessage updates conversations list with new conversation`() = test { + val botResponse = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "Bot response", false)), + lastMessage = "Bot response" ) - whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + whenever(aiBotSupportRepository.createNewConversation("Hello bot")).thenReturn(botResponse) viewModel.onNewConversationClick() - viewModel.sendMessage(message) advanceUntilIdle() - verify(aiBotSupportRepository).createNewConversation(message) - assertThat(viewModel.conversations.value).contains(newConversation) - assertThat(viewModel.isBotTyping.value).isFalse - assertThat(viewModel.canSendMessage.value).isTrue + viewModel.sendMessage("Hello bot") + advanceUntilIdle() + + assertThat(viewModel.conversations.value).hasSize(1) + assertThat(viewModel.conversations.value.first().id).isEqualTo(1) } @Test - fun `sendMessage sends to existing conversation when id is not 0`() = test { - val conversationId = 456L - val message = "Follow-up question" - val existingConversation = createTestConversation(id = conversationId).copy( - messages = listOf(BotMessage(1L, "Previous message", Date(), true)) - ) - val updatedConversation = existingConversation.copy( - messages = listOf( - BotMessage(1L, "Previous message", Date(), true), - BotMessage(2L, message, Date(), true), - BotMessage(3L, "Bot response", Date(), false) - ) - ) + fun `sendMessage updates existing conversation in conversations list`() = test { + val initialConversations = listOf(createTestConversation(1), createTestConversation(2)) + whenever(aiBotSupportRepository.loadConversations()).thenReturn(initialConversations) + whenever(aiBotSupportRepository.loadConversation(1L)).thenReturn(initialConversations[0]) - whenever(aiBotSupportRepository.loadConversation(conversationId)).thenReturn(existingConversation) - whenever(aiBotSupportRepository.sendMessageToConversation(eq(conversationId), eq(message))) - .thenReturn(updatedConversation) + viewModel.init() + advanceUntilIdle() - viewModel.onConversationSelected(existingConversation) + viewModel.onConversationClick(initialConversations[0]) advanceUntilIdle() - viewModel.sendMessage(message) + val updatedConversation = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "Bot response", false)), + lastMessage = "Bot response" + ) + whenever(aiBotSupportRepository.sendMessageToConversation(eq(1L), eq("Hello"))) + .thenReturn(updatedConversation) + + viewModel.sendMessage("Hello") advanceUntilIdle() - verify(aiBotSupportRepository).sendMessageToConversation(conversationId, message) - assertThat(viewModel.isBotTyping.value).isFalse - assertThat(viewModel.canSendMessage.value).isTrue + assertThat(viewModel.conversations.value).hasSize(2) + assertThat(viewModel.conversations.value.first().id).isEqualTo(1) + assertThat(viewModel.conversations.value.first().lastMessage).isEqualTo("Bot response") } @Test - fun `sendMessage shows bot typing indicator during operation`() = test { - val message = "Test message" - val newConversation = createTestConversation(id = 123L) - whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + fun `sendMessage merges user message and bot messages correctly`() = test { + val botResponse = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "Bot response", false)) + ) + whenever(aiBotSupportRepository.createNewConversation("Hello bot")).thenReturn(botResponse) viewModel.onNewConversationClick() - viewModel.sendMessage(message) advanceUntilIdle() - assertThat(viewModel.isBotTyping.value).isFalse + viewModel.sendMessage("Hello bot") + advanceUntilIdle() + + 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?.last()?.isWrittenByUser).isFalse + assertThat(selectedConversation?.messages?.last()?.text).isEqualTo("Bot response") } @Test - fun `sendMessage disables message sending during operation`() = test { - val message = "Test message" - val newConversation = createTestConversation(id = 123L) - whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + fun `sendMessage sets lastMessage from bot response`() = test { + val botResponse = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "Latest bot message", false)) + ) + whenever(aiBotSupportRepository.createNewConversation("Hello")).thenReturn(botResponse) viewModel.onNewConversationClick() - assertThat(viewModel.canSendMessage.value).isTrue + advanceUntilIdle() - viewModel.sendMessage(message) + viewModel.sendMessage("Hello") advanceUntilIdle() - assertThat(viewModel.canSendMessage.value).isTrue + val selectedConversation = viewModel.selectedConversation.value + assertThat(selectedConversation?.lastMessage).isEqualTo("Latest bot message") } @Test - fun `sendMessage adds user message optimistically to selected conversation`() = test { - val message = "Test message" - val newConversation = createTestConversation(id = 123L) - whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + fun `sendMessage sets error when response is null`() = test { + whenever(aiBotSupportRepository.createNewConversation(any())).thenReturn(null) viewModel.onNewConversationClick() - viewModel.sendMessage(message) + advanceUntilIdle() - // Allow the optimistic update to complete + viewModel.sendMessage("Hello bot") advanceUntilIdle() - val selectedConversation = viewModel.selectedConversation.value - assertThat(selectedConversation?.messages).isNotEmpty - assertThat(selectedConversation?.messages?.first()?.text).isEqualTo(message) - assertThat(selectedConversation?.messages?.first()?.isWrittenByUser).isTrue + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), any()) } @Test - fun `sendMessage sets error when repository returns null`() = test { - val message = "Test message" - whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(null) + fun `sendMessage sets error and resets typing state when exception occurs`() = test { + whenever(aiBotSupportRepository.createNewConversation(any())).thenThrow(RuntimeException("Network error")) viewModel.onNewConversationClick() - viewModel.sendMessage(message) advanceUntilIdle() - assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) + viewModel.sendMessage("Hello bot") + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) assertThat(viewModel.isBotTyping.value).isFalse assertThat(viewModel.canSendMessage.value).isTrue verify(appLogWrapper).e(any(), any()) } @Test - fun `sendMessage sets error and re-enables sending when exception occurs`() = test { - val message = "Test message" - val exception = RuntimeException("Send failed") - whenever(aiBotSupportRepository.createNewConversation(message)).thenThrow(exception) + fun `sendMessage resets canSendMessage to true even when error occurs`() = test { + whenever(aiBotSupportRepository.createNewConversation(any())).thenThrow(RuntimeException("Error")) viewModel.onNewConversationClick() - viewModel.sendMessage(message) advanceUntilIdle() - assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isBotTyping.value).isFalse + viewModel.sendMessage("Hello") + advanceUntilIdle() + assertThat(viewModel.canSendMessage.value).isTrue - verify(appLogWrapper).e(any(), any()) } @Test - fun `sendMessage updates conversations list when creating new conversation`() = test { - val initialConversations = createTestConversations(count = 2) - val message = "New conversation" - val newConversation = createTestConversation(id = 999L).copy( - messages = listOf( - BotMessage(1L, message, Date(), true), - BotMessage(2L, "Bot response", Date(), false) - ) + fun `sendMessage multiple times accumulates messages`() = test { + val firstBotResponse = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "First bot response", false)) + ) + val secondBotResponse = createTestConversation(1).copy( + messages = listOf(createTestMessage(2, "Second bot response", false)) ) - whenever(aiBotSupportRepository.loadConversations()).thenReturn(initialConversations) - whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + whenever(aiBotSupportRepository.createNewConversation("First message")).thenReturn(firstBotResponse) + whenever(aiBotSupportRepository.sendMessageToConversation(eq(1L), eq("Second message"))) + .thenReturn(secondBotResponse) - viewModel.init(testAccessToken, testUserId) + viewModel.onNewConversationClick() advanceUntilIdle() - viewModel.onNewConversationClick() - viewModel.sendMessage(message) + viewModel.sendMessage("First message") + advanceUntilIdle() + + viewModel.sendMessage("Second message") advanceUntilIdle() - assertThat(viewModel.conversations.value).hasSize(3) - assertThat(viewModel.conversations.value.first().getConversationId).isEqualTo(999L) + val selectedConversation = viewModel.selectedConversation.value + assertThat(selectedConversation?.messages).hasSize(4) + assertThat(selectedConversation?.messages?.filter { it.isWrittenByUser }).hasSize(2) + assertThat(selectedConversation?.messages?.filter { !it.isWrittenByUser }).hasSize(2) } - @Test - fun `sendMessage updates existing conversation in conversations list`() = test { - val conversationId = 123L - val existingConversation = createTestConversation(id = conversationId).copy( - messages = listOf(BotMessage(1L, "Previous message", Date(), true)) - ) - val initialConversations = listOf(existingConversation, createTestConversation(id = 456L)) - val message = "Follow-up" - val updatedConversation = existingConversation.copy( - messages = listOf( - BotMessage(1L, "Previous message", Date(), true), - BotMessage(2L, message, Date(), true), - BotMessage(3L, "Bot response", Date(), false) - ) - ) + // endregion - whenever(aiBotSupportRepository.loadConversations()).thenReturn(initialConversations) - whenever(aiBotSupportRepository.loadConversation(conversationId)).thenReturn(existingConversation) - whenever(aiBotSupportRepository.sendMessageToConversation(conversationId, message)) - .thenReturn(updatedConversation) + // region Override methods tests - viewModel.init(testAccessToken, testUserId) - advanceUntilIdle() + @Test + fun `initRepository calls repository init with correct parameters`() = test { + whenever(aiBotSupportRepository.loadConversations()).thenReturn(emptyList()) - viewModel.onConversationSelected(existingConversation) + viewModel.init() advanceUntilIdle() - viewModel.sendMessage(message) + verify(aiBotSupportRepository).init(testAccessToken, testUserId) + } + + @Test + fun `getConversations calls repository loadConversations`() = test { + val conversations = listOf(createTestConversation(1), createTestConversation(2)) + whenever(aiBotSupportRepository.loadConversations()).thenReturn(conversations) + + viewModel.init() advanceUntilIdle() - val updatedList = viewModel.conversations.value - assertThat(updatedList).hasSize(2) - val updatedInList = updatedList.find { it.getConversationId == conversationId } - assertThat(updatedInList?.lastMessage).isEqualTo("Bot response") + verify(aiBotSupportRepository).loadConversations() + assertThat(viewModel.conversations.value).isEqualTo(conversations) } + // endregion + // Helper functions private fun createTestConversation( id: Long, @@ -369,7 +380,16 @@ class AIBotSupportViewModelTest : BaseUnitTest() { ) } - private fun createTestConversations(count: Int = 2): List { - return (1..count).map { createTestConversation(id = it.toLong(), lastMessage = "Message $it") } + private fun createTestMessage( + id: Long, + text: String, + isWrittenByUser: Boolean + ): BotMessage { + return BotMessage( + id = id, + text = text, + date = Date(), + isWrittenByUser = isWrittenByUser + ) } } From 204afeff87bd919766df59178a47b1aa2bc634b0 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 16:13:34 +0200 Subject: [PATCH 55/81] detekt --- .../android/support/aibot/ui/AIBotSupportActivity.kt | 4 ---- .../support/common/ui/ConversationsSupportViewModel.kt | 6 ++++++ .../wordpress/android/support/he/ui/HESupportActivity.kt | 4 ---- .../support/common/ui/ConversationsSupportViewModelTest.kt | 7 ++++--- .../android/support/he/ui/HESupportViewModelTest.kt | 4 +++- 5 files changed, 13 insertions(+), 12 deletions(-) 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 98f2db0685d4..829e8da04c3c 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 @@ -6,17 +6,13 @@ import android.os.Build import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle 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 a1856402920f..be5b7ffa58d6 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 @@ -28,21 +28,26 @@ abstract class ConversationsSupportViewModel( private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + @Suppress("VariableNaming") protected val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations.asStateFlow() private val _isLoadingConversation = MutableStateFlow(false) val isLoadingConversation: StateFlow = _isLoadingConversation.asStateFlow() + @Suppress("VariableNaming") protected val _selectedConversation = MutableStateFlow(null) val selectedConversation: StateFlow = _selectedConversation.asStateFlow() + @Suppress("VariableNaming") protected val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) val userInfo: StateFlow = _userInfo.asStateFlow() + @Suppress("VariableNaming") protected val _isLoadingConversations = MutableStateFlow(false) val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() + @Suppress("VariableNaming") protected val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() @@ -122,6 +127,7 @@ abstract class ConversationsSupportViewModel( // Region navigation + @Suppress("TooGenericExceptionCaught") fun onConversationClick(conversation: ConversationType) { viewModelScope.launch { try { 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 292e3c926aa7..871adf063f57 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 @@ -6,17 +6,13 @@ import android.os.Build import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.foundation.layout.padding -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle 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 10b522fe9c97..81aa5848fb50 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 @@ -1,7 +1,6 @@ package org.wordpress.android.support.common.ui import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -226,7 +225,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { fun `onConversationClick sets loading state to false after loading`() = test { val conversation = createTestConversation(1) viewModel.setConversationToReturn(conversation) - + viewModel.onConversationClick(conversation) advanceUntilIdle() @@ -354,7 +353,6 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { accountStore: AccountStore, appLogWrapper: AppLogWrapper ) : ConversationsSupportViewModel(accountStore, appLogWrapper) { - var initRepositoryCalled = false private var shouldThrowOnInit = false private var shouldThrowOnGetConversations = false @@ -382,6 +380,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { conversationToReturn = conversation } + @Suppress("TooGenericExceptionThrown") override fun initRepository(accessToken: String) { if (shouldThrowOnInit) { throw RuntimeException("Init failed") @@ -389,6 +388,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { initRepositoryCalled = true } + @Suppress("TooGenericExceptionThrown") override suspend fun getConversations(): List { if (shouldThrowOnGetConversations) { throw RuntimeException("Get conversations failed") @@ -396,6 +396,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { return conversationsToReturn } + @Suppress("TooGenericExceptionThrown") override suspend fun getConversation(conversationId: Long): TestConversation? { if (shouldThrowOnGetConversation) { throw RuntimeException("Get conversation failed") 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 b505d014188e..a30d7888dc59 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 @@ -265,7 +265,9 @@ class HESupportViewModelTest : BaseUnitTest() { // Then assertThat(firstEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateToNewTicket) - assertThat(secondEvent).isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) + assertThat(secondEvent).isInstanceOf( + HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java + ) } } From a304d67092c8d9b9b9c804a4af96aeab9a660bf8 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 16:52:49 +0200 Subject: [PATCH 56/81] Improvements int he conversation interaction --- .../he/ui/HEConversationDetailScreen.kt | 78 ++++++++++++++----- .../support/he/ui/HESupportActivity.kt | 10 ++- .../support/he/ui/HESupportViewModel.kt | 1 - .../support/he/ui/TicketMainContentView.kt | 12 ++- 4 files changed, 77 insertions(+), 24 deletions(-) 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 a6145a49c6b8..d351fb58908a 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 @@ -34,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -62,7 +63,9 @@ fun HEConversationDetailScreen( snackbarHostState: SnackbarHostState, conversation: SupportConversation, isLoading: Boolean = false, - onBackClick: () -> Unit + isSendingMessage: Boolean = false, + onBackClick: () -> Unit, + onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -70,6 +73,13 @@ fun HEConversationDetailScreen( var showBottomSheet by remember { mutableStateOf(false) } val resources = LocalResources.current + // Scroll to bottom when conversation changes or new messages arrive + LaunchedEffect(conversation.messages.size) { + if (conversation.messages.isNotEmpty()) { + listState.animateScrollToItem(conversation.messages.size + 1) // +1 for header and title + } + } + Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { @@ -81,6 +91,7 @@ fun HEConversationDetailScreen( }, bottomBar = { ReplyButton( + enabled = !isLoading, onClick = { showBottomSheet = true } @@ -138,6 +149,7 @@ fun HEConversationDetailScreen( if (showBottomSheet) { ReplyBottomSheet( sheetState = sheetState, + isSending = isSendingMessage, onDismiss = { scope.launch { sheetState.hide() @@ -146,12 +158,7 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> - /* Placeholder for send functionality */ - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - showBottomSheet = false - } + onSendMessage(message, includeAppLogs) } ) } @@ -268,7 +275,10 @@ private fun MessageItem( } @Composable -private fun ReplyButton(onClick: () -> Unit) { +private fun ReplyButton( + enabled: Boolean = true, + onClick: () -> Unit +) { Box( modifier = Modifier .fillMaxWidth() @@ -276,6 +286,7 @@ private fun ReplyButton(onClick: () -> Unit) { ) { Button( onClick = onClick, + enabled = enabled, modifier = Modifier .fillMaxWidth() .height(56.dp), @@ -299,12 +310,28 @@ private fun ReplyButton(onClick: () -> Unit) { @Composable private fun ReplyBottomSheet( sheetState: androidx.compose.material3.SheetState, + isSending: Boolean = false, onDismiss: () -> Unit, onSend: (String, Boolean) -> Unit ) { var messageText by remember { mutableStateOf("") } var includeAppLogs by remember { mutableStateOf(false) } val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + var wasSending by remember { mutableStateOf(false) } + + // Close the sheet when sending completes successfully + LaunchedEffect(isSending) { + if (wasSending && !isSending) { + // Sending completed, close the sheet + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + onDismiss() + } + } + wasSending = isSending + } ModalBottomSheet( onDismissRequest = onDismiss, @@ -325,7 +352,10 @@ private fun ReplyBottomSheet( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - TextButton(onClick = onDismiss) { + TextButton( + onClick = onDismiss, + enabled = !isSending + ) { Text( text = stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium @@ -340,12 +370,19 @@ private fun ReplyBottomSheet( TextButton( onClick = { onSend(messageText, includeAppLogs) }, - enabled = messageText.isNotBlank() + enabled = messageText.isNotBlank() && !isSending ) { - Text( - text = stringResource(R.string.he_support_send_button), - style = MaterialTheme.typography.titleMedium - ) + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Text( + text = stringResource(R.string.he_support_send_button), + style = MaterialTheme.typography.titleMedium + ) + } } } @@ -354,6 +391,7 @@ private fun ReplyBottomSheet( includeAppLogs = includeAppLogs, onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, + enabled = !isSending ) } } @@ -369,7 +407,8 @@ private fun HEConversationDetailScreenPreview() { HEConversationDetailScreen( snackbarHostState = snackbarHostState, conversation = sampleConversation, - onBackClick = { } + onBackClick = { }, + onSendMessage = { _, _ -> } ) } } @@ -384,7 +423,8 @@ private fun HEConversationDetailScreenPreviewDark() { HEConversationDetailScreen( snackbarHostState = snackbarHostState, conversation = sampleConversation, - onBackClick = { } + onBackClick = { }, + onSendMessage = { _, _ -> } ) } } @@ -399,7 +439,8 @@ private fun HEConversationDetailScreenWordPressPreview() { HEConversationDetailScreen( snackbarHostState = snackbarHostState, conversation = sampleConversation, - onBackClick = { } + onBackClick = { }, + onSendMessage = { _, _ -> } ) } } @@ -415,7 +456,8 @@ private fun HEConversationDetailScreenPreviewWordPressDark() { snackbarHostState = snackbarHostState, isLoading = true, conversation = sampleConversation, - onBackClick = { } + onBackClick = { }, + onSendMessage = { _, _ -> } ) } } 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 871adf063f57..d69375f02df1 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 @@ -128,12 +128,20 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.Detail.name) { val selectedConversation by viewModel.selectedConversation.collectAsState() val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() + val isSendingMessage by viewModel.isSendingNewConversation.collectAsState() selectedConversation?.let { conversation -> HEConversationDetailScreen( snackbarHostState = snackbarHostState, conversation = conversation, isLoading = isLoadingConversation, - onBackClick = { viewModel.onBackFromDetailClick() } + isSendingMessage = isSendingMessage, + onBackClick = { viewModel.onBackFromDetailClick() }, + onSendMessage = { message, includeAppLogs -> + viewModel.onAddMessageToConversation( + message = message, + attachments = emptyList() + ) + } ) } } 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 c54e37066b96..b025762c42be 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 @@ -88,7 +88,6 @@ class HESupportViewModel @Inject constructor( )) { is CreateConversationResult.Success -> { _selectedConversation.value = result.conversation - // TODO refresh conversation and scroll to bottom } is CreateConversationResult.Error.Unauthorized -> { 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 96931e861bfa..ac4b8fb3f129 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 @@ -37,7 +37,8 @@ fun TicketMainContentView( messageText: String, includeAppLogs: Boolean, onMessageChanged: (String) -> Unit, - onIncludeAppLogsChanged: (Boolean) -> Unit + onIncludeAppLogsChanged: (Boolean) -> Unit, + enabled: Boolean = true ) { Column( modifier = Modifier @@ -58,7 +59,8 @@ fun TicketMainContentView( .fillMaxWidth() .height(200.dp), shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + enabled = enabled ) Spacer(modifier = Modifier.height(24.dp)) @@ -80,7 +82,8 @@ fun TicketMainContentView( Button( onClick = { /* Placeholder for add screenshots */ }, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(12.dp), + enabled = enabled ) { Icon( imageVector = Icons.Default.CameraAlt, @@ -133,7 +136,8 @@ fun TicketMainContentView( Switch( checked = includeAppLogs, - onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) } + onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) }, + enabled = enabled ) } } From 6ee853bc59c9376a203b75d113dabdc47fee92a2 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 22 Oct 2025 17:08:28 +0200 Subject: [PATCH 57/81] Adding tests for HE VM --- .../support/he/ui/HESupportViewModelTest.kt | 485 ++++++++++-------- 1 file changed, 277 insertions(+), 208 deletions(-) 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 a30d7888dc59..d1b9386ec4e3 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,37 +1,57 @@ package org.wordpress.android.support.he.ui -import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest 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.ui.ConversationsSupportViewModel 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 java.util.Date @ExperimentalCoroutinesApi class HESupportViewModelTest : BaseUnitTest() { @Mock - lateinit var accountStore: AccountStore + private lateinit var accountStore: AccountStore @Mock - lateinit var account: AccountModel + private lateinit var heSupportRepository: HESupportRepository @Mock - lateinit var heSupportRepository: org.wordpress.android.support.he.repository.HESupportRepository - - @Mock - lateinit var appLogWrapper: org.wordpress.android.fluxc.utils.AppLogWrapper + private lateinit var appLogWrapper: AppLogWrapper private lateinit var viewModel: HESupportViewModel + private val testAccessToken = "test_access_token" + private val testUserId = 12345L + private val testUserName = "Test User" + private val testUserEmail = "test@example.com" + private val testAvatarUrl = "https://example.com/avatar.jpg" + @Before fun setUp() { + val accountModel = AccountModel().apply { + displayName = testUserName + userName = "testuser" + email = testUserEmail + avatarUrl = testAvatarUrl + userId = testUserId + } + whenever(accountStore.account).thenReturn(accountModel) + whenever(accountStore.hasAccessToken()).thenReturn(true) + whenever(accountStore.accessToken).thenReturn(testAccessToken) + viewModel = HESupportViewModel( accountStore = accountStore, heSupportRepository = heSupportRepository, @@ -39,267 +59,310 @@ class HESupportViewModelTest : BaseUnitTest() { ) } - // region init() tests + // region StateFlow initial values tests @Test - fun `init loads user info when account exists`() { - // Given - val displayName = "Test User" - val email = "test@example.com" - val avatarUrl = "https://example.com/avatar.jpg" - - whenever(accountStore.account).thenReturn(account) - whenever(account.displayName).thenReturn(displayName) - whenever(account.email).thenReturn(email) - whenever(account.avatarUrl).thenReturn(avatarUrl) - - // When - viewModel.init() - - // Then - assertThat(viewModel.userInfo.value.userName).isEqualTo(displayName) - assertThat(viewModel.userInfo.value.userEmail).isEqualTo(email) - assertThat(viewModel.userInfo.value.avatarUrl).isEqualTo(avatarUrl) + fun `isSendingNewConversation is false initially`() { + assertThat(viewModel.isSendingNewConversation.value).isFalse } + // endregion + + // region initRepository() override tests + @Test - fun `init uses userName when displayName is empty`() { - // Given - val userName = "testuser" - val email = "test@example.com" - - whenever(accountStore.account).thenReturn(account) - whenever(account.displayName).thenReturn("") - whenever(account.userName).thenReturn(userName) - whenever(account.email).thenReturn(email) - whenever(account.avatarUrl).thenReturn("") - - // When + fun `initRepository calls repository init with correct access token`() = test { + whenever(heSupportRepository.loadConversations()).thenReturn(emptyList()) + viewModel.init() + advanceUntilIdle() - // Then - assertThat(viewModel.userInfo.value.userName).isEqualTo(userName) + verify(heSupportRepository).init(testAccessToken) } + // endregion + + // region getConversations() override tests + @Test - fun `init sets avatarUrl to null when empty`() { - // Given - whenever(accountStore.account).thenReturn(account) - whenever(account.displayName).thenReturn("Test User") - whenever(account.email).thenReturn("test@example.com") - whenever(account.avatarUrl).thenReturn("") - - // When + fun `getConversations calls repository loadConversations`() = test { + val conversations = listOf(createTestConversation(1), createTestConversation(2)) + whenever(heSupportRepository.loadConversations()).thenReturn(conversations) + viewModel.init() + advanceUntilIdle() - // Then - assertThat(viewModel.userInfo.value.avatarUrl).isNull() + verify(heSupportRepository).loadConversations() + assertThat(viewModel.conversations.value).isEqualTo(conversations) } // endregion - // region onConversationClick() tests + // region onSendNewConversation() tests @Test - fun `onConversationClick updates selected conversation`() { - // Given - val conversation = createTestConversation() + fun `onSendNewConversation creates new conversation successfully`() = test { + val newConversation = createTestConversation(1) + whenever(heSupportRepository.createConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + )).thenReturn(CreateConversationResult.Success(newConversation)) - // When - viewModel.onConversationClick(conversation) + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + ) + advanceUntilIdle() - // Then - assertThat(viewModel.selectedConversation.value).isEqualTo(conversation) + verify(heSupportRepository).createConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + ) } @Test - fun `onConversationClick emits NavigateToConversationDetail event`() = test { - // Given - val conversation = createTestConversation() - - // When - viewModel.navigationEvents.test { - viewModel.onConversationClick(conversation) - - // Then - val event = awaitItem() - assertThat(event).isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) - val navigateEvent = event as HESupportViewModel.NavigationEvent.NavigateToConversationDetail - assertThat(navigateEvent.conversation).isEqualTo(conversation) - } - } + fun `onSendNewConversation calls onConversationClick on success`() = test { + val newConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(newConversation) + whenever(heSupportRepository.createConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + )).thenReturn(CreateConversationResult.Success(newConversation)) - // endregion + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + ) + advanceUntilIdle() - // region onBackFromDetailClick() tests + assertThat(viewModel.selectedConversation.value).isEqualTo(newConversation) + } @Test - fun `onBackFromDetailClick emits NavigateBack event`() = test { - // When - viewModel.navigationEvents.test { - viewModel.onBackFromDetailClick() - - // Then - val event = awaitItem() - assertThat(event).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) - } - } + fun `onSendNewConversation sets FORBIDDEN error on Unauthorized result`() = test { + whenever(heSupportRepository.createConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + )).thenReturn(CreateConversationResult.Error.Unauthorized) - // endregion + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + ) + advanceUntilIdle() - // region onCreateNewConversation() tests + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.FORBIDDEN) + verify(appLogWrapper).e(any(), eq("Unauthorized error creating HE conversation")) + } @Test - fun `onCreateNewConversation emits NavigateToNewTicket event`() = test { - // When - viewModel.navigationEvents.test { - viewModel.onCreateNewConversationClick() - - // Then - val event = awaitItem() - assertThat(event).isEqualTo(HESupportViewModel.NavigationEvent.NavigateToNewTicket) - } - } + fun `onSendNewConversation sets GENERAL error on GeneralError result`() = test { + whenever(heSupportRepository.createConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + )).thenReturn(CreateConversationResult.Error.GeneralError) - // endregion + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = emptyList() + ) + advanceUntilIdle() - // region onSendNewConversation() tests + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), eq("General error creating HE conversation")) + } @Test - fun `onSendNewConversation emits NavigateToConversationDetail event on success`() = test { - // Given - val testConversation = createTestConversation() + fun `onSendNewConversation resets isSendingNewConversation even when error occurs`() = test { whenever(heSupportRepository.createConversation( + any(), any(), any(), any() + )).thenReturn(CreateConversationResult.Error.GeneralError) + + viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", tags = emptyList(), attachments = emptyList() - )).thenReturn(org.wordpress.android.support.he.repository.CreateConversationResult.Success(testConversation)) - - // When - viewModel.navigationEvents.test { - viewModel.onSendNewConversation( - subject = "Test Subject", - message = "Test Message", - tags = emptyList(), - attachments = emptyList() - ) - - // Then - val event = awaitItem() - assertThat(event).isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) - } + ) + advanceUntilIdle() + + assertThat(viewModel.isSendingNewConversation.value).isFalse } // endregion - // region StateFlow initial values tests + // region getConversation() override tests @Test - fun `conversations is empty before init`() { - // Then - assertThat(viewModel.conversations.value).isEmpty() + fun `getConversation calls repository loadConversation with correct id`() = test { + val conversation = createTestConversation(5) + whenever(heSupportRepository.loadConversation(5L)).thenReturn(conversation) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + verify(heSupportRepository).loadConversation(5L) } + // endregion + + // region onAddMessageToConversation() tests + @Test - fun `selectedConversation is null before init`() { - // Then - assertThat(viewModel.selectedConversation.value).isNull() + fun `onAddMessageToConversation does nothing when no conversation is selected`() = test { + viewModel.onAddMessageToConversation( + message = "Test message", + attachments = emptyList() + ) + advanceUntilIdle() + + verify(appLogWrapper).e(any(), eq("Error answering a conversation: no conversation selected")) + assertThat(viewModel.isSendingNewConversation.value).isFalse } @Test - fun `userInfo has correct initial values before init`() { - // Then - assertThat(viewModel.userInfo.value.userName).isEmpty() - assertThat(viewModel.userInfo.value.userEmail).isEmpty() - assertThat(viewModel.userInfo.value.avatarUrl).isNull() + fun `onAddMessageToConversation calls repository with correct parameters`() = test { + val existingConversation = createTestConversation(1) + val updatedConversation = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "New message", true)) + ) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(heSupportRepository.addMessageToConversation( + conversationId = 1L, + message = "Test message", + attachments = listOf("attachment1") + )).thenReturn(CreateConversationResult.Success(updatedConversation)) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.onAddMessageToConversation( + message = "Test message", + attachments = listOf("attachment1") + ) + advanceUntilIdle() + + verify(heSupportRepository).addMessageToConversation( + conversationId = 1L, + message = "Test message", + attachments = listOf("attachment1") + ) } - // endregion + @Test + fun `onAddMessageToConversation updates selectedConversation on success`() = test { + val existingConversation = createTestConversation(1) + val updatedConversation = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "New message", true)) + ) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(heSupportRepository.addMessageToConversation( + conversationId = 1L, + message = "Test message", + attachments = emptyList() + )).thenReturn(CreateConversationResult.Success(updatedConversation)) - // region Navigation event sequence tests + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() - @Test - fun `can navigate to detail and back in sequence`() = test { - // Given - val conversation = createTestConversation() - - // When - viewModel.navigationEvents.test { - viewModel.onConversationClick(conversation) - val firstEvent = awaitItem() - - viewModel.onBackFromDetailClick() - val secondEvent = awaitItem() - - // Then - assertThat(firstEvent) - .isInstanceOf(HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java) - assertThat(secondEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateBack) - } + viewModel.onAddMessageToConversation( + message = "Test message", + attachments = emptyList() + ) + advanceUntilIdle() + + assertThat(viewModel.selectedConversation.value).isEqualTo(updatedConversation) } @Test - fun `can create new ticket and send in sequence`() = test { - // Given - val testConversation = createTestConversation() - whenever(heSupportRepository.createConversation( - subject = "Test", - message = "Test", - tags = emptyList(), + fun `onAddMessageToConversation sets FORBIDDEN error on Unauthorized result`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(heSupportRepository.addMessageToConversation( + conversationId = 1L, + message = "Test message", attachments = emptyList() - )).thenReturn(org.wordpress.android.support.he.repository.CreateConversationResult.Success(testConversation)) - - // When - viewModel.navigationEvents.test { - viewModel.onCreateNewConversationClick() - val firstEvent = awaitItem() - - viewModel.onSendNewConversation( - subject = "Test", - message = "Test", - tags = emptyList(), - attachments = emptyList() - ) - val secondEvent = awaitItem() - - // Then - assertThat(firstEvent).isEqualTo(HESupportViewModel.NavigationEvent.NavigateToNewTicket) - assertThat(secondEvent).isInstanceOf( - HESupportViewModel.NavigationEvent.NavigateToConversationDetail::class.java - ) - } + )).thenReturn(CreateConversationResult.Error.Unauthorized) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.onAddMessageToConversation( + message = "Test message", + attachments = emptyList() + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.FORBIDDEN) + verify(appLogWrapper).e(any(), eq("Unauthorized error adding message to HE conversation")) } - // endregion + @Test + fun `onAddMessageToConversation sets GENERAL error on GeneralError result`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(heSupportRepository.addMessageToConversation( + conversationId = 1L, + message = "Test message", + attachments = emptyList() + )).thenReturn(CreateConversationResult.Error.GeneralError) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() - // region Multiple conversation selection tests + viewModel.onAddMessageToConversation( + message = "Test message", + attachments = emptyList() + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), eq("General error adding message to HE conversation")) + } @Test - fun `selecting different conversations updates selectedConversation`() { - // Given - val conversation1 = createTestConversation(id = 1L, title = "First") - val conversation2 = createTestConversation(id = 2L, title = "Second") - - // When - viewModel.onConversationClick(conversation1) - val firstSelection = viewModel.selectedConversation.value - - viewModel.onConversationClick(conversation2) - val secondSelection = viewModel.selectedConversation.value - - // Then - assertThat(firstSelection).isEqualTo(conversation1) - assertThat(secondSelection).isEqualTo(conversation2) - assertThat(secondSelection).isNotEqualTo(firstSelection) + fun `onAddMessageToConversation resets isSendingNewConversation even when error occurs`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(heSupportRepository.addMessageToConversation( + any(), any(), any() + )).thenReturn(CreateConversationResult.Error.GeneralError) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.onAddMessageToConversation( + message = "Test message", + attachments = emptyList() + ) + advanceUntilIdle() + + assertThat(viewModel.isSendingNewConversation.value).isFalse } // endregion - // Helper methods - + // Helper functions private fun createTestConversation( - id: Long = 1L, + id: Long, title: String = "Test Conversation", description: String = "Test Description" ): SupportConversation { @@ -307,16 +370,22 @@ class HESupportViewModelTest : BaseUnitTest() { id = id, title = title, description = description, - lastMessageSentAt = Date(System.currentTimeMillis()), - messages = listOf( - SupportMessage( - id = 1L, - text = "Test message", - createdAt = Date(System.currentTimeMillis()), - authorName = "Test Author", - authorIsUser = true - ) - ) + lastMessageSentAt = Date(), + messages = emptyList() + ) + } + + private fun createTestMessage( + id: Long, + text: String, + authorIsUser: Boolean + ): SupportMessage { + return SupportMessage( + id = id, + text = text, + createdAt = Date(), + authorName = if (authorIsUser) "User" else "Support", + authorIsUser = authorIsUser ) } } From a13ae5b86125c673435a6eec6df594b5fb631baa Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 10:22:24 +0200 Subject: [PATCH 58/81] Saving draft state --- .../he/ui/HEConversationDetailScreen.kt | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) 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 d351fb58908a..7a277a4b4b0a 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 @@ -73,6 +73,10 @@ fun HEConversationDetailScreen( var showBottomSheet by remember { mutableStateOf(false) } val resources = LocalResources.current + // Save draft message state to restore when reopening the bottom sheet + var draftMessageText by remember { mutableStateOf("") } + var draftIncludeAppLogs by remember { mutableStateOf(false) } + // Scroll to bottom when conversation changes or new messages arrive LaunchedEffect(conversation.messages.size) { if (conversation.messages.isNotEmpty()) { @@ -113,7 +117,8 @@ fun HEConversationDetailScreen( item { ConversationHeader( messageCount = conversation.messages.size, - lastUpdated = formatRelativeTime(conversation.lastMessageSentAt, resources) + lastUpdated = formatRelativeTime(conversation.lastMessageSentAt, resources), + isLoading = isLoading ) } @@ -150,7 +155,12 @@ fun HEConversationDetailScreen( ReplyBottomSheet( sheetState = sheetState, isSending = isSendingMessage, - onDismiss = { + initialMessageText = draftMessageText, + initialIncludeAppLogs = draftIncludeAppLogs, + onDismiss = { currentMessage, currentIncludeAppLogs -> + // Save draft message when closing without sending + draftMessageText = currentMessage + draftIncludeAppLogs = currentIncludeAppLogs scope.launch { sheetState.hide() }.invokeOnCompletion { @@ -158,6 +168,9 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> + // Clear draft after successful send + draftMessageText = "" + draftIncludeAppLogs = false onSendMessage(message, includeAppLogs) } ) @@ -167,7 +180,8 @@ fun HEConversationDetailScreen( @Composable private fun ConversationHeader( messageCount: Int, - lastUpdated: String + lastUpdated: String, + isLoading: Boolean = false ) { Row( modifier = Modifier @@ -176,21 +190,25 @@ private fun ConversationHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.ic_comment_white_24dp), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - Text( - text = stringResource(R.string.he_support_message_count, messageCount), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (!isLoading) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_comment_white_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.he_support_message_count, messageCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Spacer(modifier = Modifier.size(0.dp)) } Text( @@ -311,11 +329,13 @@ private fun ReplyButton( private fun ReplyBottomSheet( sheetState: androidx.compose.material3.SheetState, isSending: Boolean = false, - onDismiss: () -> Unit, + initialMessageText: String = "", + initialIncludeAppLogs: Boolean = false, + onDismiss: (currentMessage: String, currentIncludeAppLogs: Boolean) -> Unit, onSend: (String, Boolean) -> Unit ) { - var messageText by remember { mutableStateOf("") } - var includeAppLogs by remember { mutableStateOf(false) } + var messageText by remember { mutableStateOf(initialMessageText) } + var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } val scrollState = rememberScrollState() val scope = rememberCoroutineScope() var wasSending by remember { mutableStateOf(false) } @@ -323,18 +343,18 @@ private fun ReplyBottomSheet( // Close the sheet when sending completes successfully LaunchedEffect(isSending) { if (wasSending && !isSending) { - // Sending completed, close the sheet + // Sending completed, close the sheet (with empty draft since message was sent) scope.launch { sheetState.hide() }.invokeOnCompletion { - onDismiss() + onDismiss("", false) } } wasSending = isSending } ModalBottomSheet( - onDismissRequest = onDismiss, + onDismissRequest = { onDismiss(messageText, includeAppLogs) }, sheetState = sheetState ) { Column( @@ -353,7 +373,7 @@ private fun ReplyBottomSheet( verticalAlignment = Alignment.CenterVertically ) { TextButton( - onClick = onDismiss, + onClick = { onDismiss(messageText, includeAppLogs) }, enabled = !isSending ) { Text( From 49f1af3607472872d4a1dc93c635798aab1e92b7 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 10:37:49 +0200 Subject: [PATCH 59/81] Properly navigating when a ticket is selected --- .../android/support/aibot/ui/AIBotSupportActivity.kt | 2 +- .../support/common/ui/ConversationsSupportViewModel.kt | 2 +- .../wordpress/android/support/he/ui/HESupportActivity.kt | 4 ++-- .../wordpress/android/support/he/ui/HESupportViewModel.kt | 6 ++++-- .../support/common/ui/ConversationsSupportViewModelTest.kt | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) 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 829e8da04c3c..de6e0ac3a8fd 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 @@ -141,7 +141,7 @@ class AIBotSupportActivity : AppCompatActivity() { isLoading = isLoadingConversation, isBotTyping = isBotTyping, canSendMessage = canSendMessage, - onBackClick = { viewModel.onBackFromDetailClick() }, + onBackClick = { viewModel.onBackClick() }, onSendMessage = { text -> viewModel.sendMessage(text) } 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 be5b7ffa58d6..45ab00d1639e 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 @@ -155,7 +155,7 @@ abstract class ConversationsSupportViewModel( abstract suspend fun getConversation(conversationId: Long): ConversationType? - fun onBackFromDetailClick() { + fun onBackClick() { viewModelScope.launch { _selectedConversation.value = null _navigationEvents.emit(NavigationEvent.NavigateBack) 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 d69375f02df1..732c8dc1143a 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 @@ -135,7 +135,7 @@ class HESupportActivity : AppCompatActivity() { conversation = conversation, isLoading = isLoadingConversation, isSendingMessage = isSendingMessage, - onBackClick = { viewModel.onBackFromDetailClick() }, + onBackClick = { viewModel.onBackClick() }, onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( message = message, @@ -151,7 +151,7 @@ class HESupportActivity : AppCompatActivity() { val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() HENewTicketScreen( snackbarHostState = snackbarHostState, - onBackClick = { viewModel.onBackFromDetailClick() }, + onBackClick = { viewModel.onBackClick() }, onSubmit = { category, subject, messageText, siteAddress -> viewModel.onSendNewConversation( subject = subject, 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 b025762c42be..9c3d03cc5800 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 @@ -46,8 +46,10 @@ class HESupportViewModel @Inject constructor( attachments = attachments )) { is CreateConversationResult.Success -> { - // Simulate clicking on the conversation - onConversationClick(result.conversation) + val newConversation = result.conversation + // update conversations locally + _conversations.value = listOf(newConversation) + _conversations.value + onBackClick() } is CreateConversationResult.Error.Unauthorized -> { 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 81aa5848fb50..0e0d8d6f361a 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 @@ -278,7 +278,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { assertThat(viewModel.selectedConversation.value).isNotNull - viewModel.onBackFromDetailClick() + viewModel.onBackClick() advanceUntilIdle() assertThat(viewModel.selectedConversation.value).isNull() @@ -293,7 +293,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { } } - viewModel.onBackFromDetailClick() + viewModel.onBackClick() advanceUntilIdle() assertThat(emittedEvent).isEqualTo(ConversationsSupportViewModel.NavigationEvent.NavigateBack) From e394c7a3f312b703dfe551bedc483cde37947e1e Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 12:30:01 +0200 Subject: [PATCH 60/81] Error parsing improvement --- .../he/repository/HESupportRepository.kt | 43 +++++++++++-------- .../support/he/ui/HESupportViewModel.kt | 4 +- .../he/repository/HESupportRepositoryTest.kt | 4 +- .../support/he/ui/HESupportViewModelTest.kt | 4 +- 4 files changed, 30 insertions(+), 25 deletions(-) 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 73dff6ad6e14..d464de6328ea 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 @@ -14,6 +14,7 @@ import uniffi.wp_api.AddMessageToSupportConversationParams import uniffi.wp_api.CreateSupportTicketParams import uniffi.wp_api.SupportConversationSummary import uniffi.wp_api.SupportMessageAuthor +import uniffi.wp_api.WpErrorCode import javax.inject.Inject import javax.inject.Named import kotlin.String @@ -24,7 +25,7 @@ sealed class CreateConversationResult { data class Success(val conversation: SupportConversation) : CreateConversationResult() sealed class Error : CreateConversationResult() { - data object Unauthorized : Error() + data object Forbidden : Error() data object GeneralError : Error() } } @@ -101,24 +102,26 @@ class HESupportRepository @Inject constructor( ) } - when (response) { - is WpRequestResult.Success -> { + when { + response is WpRequestResult.Success -> { val conversation = response.response.data CreateConversationResult.Success(conversation.toSupportConversation()) } + response is WpRequestResult.WpError && response.errorCode is WpErrorCode.Forbidden -> { + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error creating support conversation - Forbidden: $response" + ) + CreateConversationResult.Error.Forbidden + } + else -> { appLogWrapper.e( AppLog.T.SUPPORT, "Error creating support conversation: $response" ) - // Parse the response string to determine error type - val responseString = response.toString() - when { - responseString.contains("401") || responseString.contains("403") -> - CreateConversationResult.Error.Unauthorized - else -> CreateConversationResult.Error.GeneralError - } + CreateConversationResult.Error.GeneralError } } } @@ -138,24 +141,26 @@ class HESupportRepository @Inject constructor( ) } - when (response) { - is WpRequestResult.Success -> { + when { + response is WpRequestResult.Success -> { val conversation = response.response.data CreateConversationResult.Success(conversation.toSupportConversation()) } + response is WpRequestResult.WpError && response.errorCode is WpErrorCode.Forbidden -> { + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error adding message to support conversation - Forbidden: $response" + ) + CreateConversationResult.Error.Forbidden + } + else -> { appLogWrapper.e( AppLog.T.SUPPORT, "Error adding message to support conversation: $response" ) - // Parse the response string to determine error type - val responseString = response.toString() - when { - responseString.contains("401") || responseString.contains("403") -> - CreateConversationResult.Error.Unauthorized - else -> CreateConversationResult.Error.GeneralError - } + CreateConversationResult.Error.GeneralError } } } 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 9c3d03cc5800..95aa45639a28 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 @@ -52,7 +52,7 @@ class HESupportViewModel @Inject constructor( onBackClick() } - is CreateConversationResult.Error.Unauthorized -> { + is CreateConversationResult.Error.Forbidden -> { _errorMessage.value = ErrorType.FORBIDDEN appLogWrapper.e(AppLog.T.SUPPORT, "Unauthorized error creating HE conversation") } @@ -92,7 +92,7 @@ class HESupportViewModel @Inject constructor( _selectedConversation.value = result.conversation } - is CreateConversationResult.Error.Unauthorized -> { + is CreateConversationResult.Error.Forbidden -> { _errorMessage.value = ErrorType.FORBIDDEN appLogWrapper.e(AppLog.T.SUPPORT, "Unauthorized error adding message to HE conversation") } 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 894a3aa22e6c..c6a6562848d3 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 @@ -229,7 +229,7 @@ class HESupportRepositoryTest : BaseUnitTest() { ) // Then - assertThat(result).isInstanceOf(CreateConversationResult.Error.Unauthorized::class.java) + assertThat(result).isInstanceOf(CreateConversationResult.Error.Forbidden::class.java) } @Test @@ -313,7 +313,7 @@ class HESupportRepositoryTest : BaseUnitTest() { ) // Then - assertThat(result).isInstanceOf(CreateConversationResult.Error.Unauthorized::class.java) + assertThat(result).isInstanceOf(CreateConversationResult.Error.Forbidden::class.java) } @Test 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 d1b9386ec4e3..ce0a17abc48b 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 @@ -155,7 +155,7 @@ class HESupportViewModelTest : BaseUnitTest() { message = "Test Message", tags = listOf("tag1"), attachments = emptyList() - )).thenReturn(CreateConversationResult.Error.Unauthorized) + )).thenReturn(CreateConversationResult.Error.Forbidden) viewModel.onSendNewConversation( subject = "Test Subject", @@ -300,7 +300,7 @@ class HESupportViewModelTest : BaseUnitTest() { conversationId = 1L, message = "Test message", attachments = emptyList() - )).thenReturn(CreateConversationResult.Error.Unauthorized) + )).thenReturn(CreateConversationResult.Error.Forbidden) viewModel.onConversationClick(existingConversation) advanceUntilIdle() From a6c421c690b10421aa424a776eb1c85a79ff099d Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 12:39:51 +0200 Subject: [PATCH 61/81] accessToken suggestion improvements --- .../android/support/common/model/UserInfo.kt | 1 - .../common/ui/ConversationsSupportViewModel.kt | 15 ++++----------- .../android/support/main/ui/SupportViewModel.kt | 3 +-- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/model/UserInfo.kt b/WordPress/src/main/java/org/wordpress/android/support/common/model/UserInfo.kt index c859502f8043..7e360d1f143a 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/model/UserInfo.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/model/UserInfo.kt @@ -1,7 +1,6 @@ package org.wordpress.android.support.common.model data class UserInfo( - val accessToken: String, val userName: String, val userEmail: String, val avatarUrl: String? = null 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 45ab00d1639e..b9bf9ef6d692 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 @@ -40,7 +40,7 @@ abstract class ConversationsSupportViewModel( val selectedConversation: StateFlow = _selectedConversation.asStateFlow() @Suppress("VariableNaming") - protected val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) + protected val _userInfo = MutableStateFlow(UserInfo("", "", "")) val userInfo: StateFlow = _userInfo.asStateFlow() @Suppress("VariableNaming") @@ -55,13 +55,7 @@ abstract class ConversationsSupportViewModel( fun init() { viewModelScope.launch { try { - // We need to check it this way because access token can be null or empty if not set - // So, we manually handle it here - val accessToken = if (accountStore.hasAccessToken()) { - accountStore.accessToken!! - } else { - null - } + val accessToken = accountStore.accessToken.takeIf { accountStore.hasAccessToken() } if (accessToken == null) { _errorMessage.value = ErrorType.FORBIDDEN appLogWrapper.e( @@ -69,7 +63,7 @@ abstract class ConversationsSupportViewModel( ) } else { initRepository(accessToken) - loadUserInfo(accessToken) + loadUserInfo() loadConversations() } } catch (throwable: Throwable) { @@ -82,10 +76,9 @@ abstract class ConversationsSupportViewModel( abstract fun initRepository(accessToken: String) - protected fun loadUserInfo(accessToken: String) { + protected fun loadUserInfo() { val account = accountStore.account _userInfo.value = UserInfo( - accessToken = accessToken, userName = account.displayName.ifEmpty { account.userName }, userEmail = account.email, avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index 0f93c081b615..e26b37c25eac 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -34,7 +34,7 @@ class SupportViewModel @Inject constructor( val showAskHappinessEngineers: Boolean = true ) - private val _userInfo = MutableStateFlow(UserInfo("", "", "", null)) + private val _userInfo = MutableStateFlow(UserInfo("", "", "")) val userInfo: StateFlow = _userInfo.asStateFlow() private val _optionsVisibility = MutableStateFlow(SupportOptionsVisibility()) @@ -52,7 +52,6 @@ class SupportViewModel @Inject constructor( val account = accountStore.account _userInfo.value = UserInfo( - accessToken = accountStore.accessToken!!, userName = account.displayName.ifEmpty { account.userName }, userEmail = account.email, avatarUrl = account.avatarUrl.takeIf { it.isNotEmpty() } From dbcb4535e9e2b13b3501ea188d18fb509292a8ad Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 12:50:12 +0200 Subject: [PATCH 62/81] General suggestions --- .../aibot/repository/AIBotSupportRepository.kt | 12 ++++++++++++ .../support/he/repository/HESupportRepository.kt | 6 ++++++ .../support/he/ui/HEConversationDetailScreen.kt | 2 +- .../android/support/he/ui/HESupportActivity.kt | 4 ++-- .../android/support/he/ui/HESupportViewModel.kt | 12 ++++++------ .../android/support/he/ui/HESupportViewModelTest.kt | 8 ++++---- 6 files changed, 31 insertions(+), 13 deletions(-) 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 b4ff0cedc7e0..8ea61acee0c7 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 @@ -24,7 +24,19 @@ class AIBotSupportRepository @Inject constructor( private val wpComApiClientProvider: WpComApiClientProvider, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { + /** + * Access token for API authentication. + * Marked as @Volatile to ensure visibility across threads since this repository is accessed + * from multiple coroutine contexts (main thread initialization, IO dispatcher for API calls). + */ + @Volatile private var accessToken: String? = null + + /** + * User ID for API operations. + * Marked as @Volatile to ensure visibility across threads. + */ + @Volatile private var userId: Long = 0 private val wpComApiClient: WpComApiClient by lazy { 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 d464de6328ea..2a5e3530d4f6 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 @@ -35,6 +35,12 @@ class HESupportRepository @Inject constructor( private val wpComApiClientProvider: WpComApiClientProvider, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { + /** + * Access token for API authentication. + * Marked as @Volatile to ensure visibility across threads since this repository is accessed + * from multiple coroutine contexts (main thread initialization, IO dispatcher for API calls). + */ + @Volatile private var accessToken: String? = null private val wpComApiClient: WpComApiClient by lazy { 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 7a277a4b4b0a..fb2f58f65b89 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 @@ -80,7 +80,7 @@ fun HEConversationDetailScreen( // Scroll to bottom when conversation changes or new messages arrive LaunchedEffect(conversation.messages.size) { if (conversation.messages.isNotEmpty()) { - listState.animateScrollToItem(conversation.messages.size + 1) // +1 for header and title + listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) } } 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 732c8dc1143a..31a932dbf1e3 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 @@ -128,7 +128,7 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.Detail.name) { val selectedConversation by viewModel.selectedConversation.collectAsState() val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() - val isSendingMessage by viewModel.isSendingNewConversation.collectAsState() + val isSendingMessage by viewModel.isSendingMessage.collectAsState() selectedConversation?.let { conversation -> HEConversationDetailScreen( snackbarHostState = snackbarHostState, @@ -148,7 +148,7 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.NewTicket.name) { val userInfo by viewModel.userInfo.collectAsState() - val isSendingNewConversation by viewModel.isSendingNewConversation.collectAsState() + val isSendingNewConversation by viewModel.isSendingMessage.collectAsState() HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { viewModel.onBackClick() }, 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 95aa45639a28..eefd2829a3a7 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 @@ -21,8 +21,8 @@ class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, appLogWrapper: AppLogWrapper, ) : ConversationsSupportViewModel(accountStore, appLogWrapper) { - private val _isSendingNewConversation = MutableStateFlow(false) - val isSendingNewConversation: StateFlow = _isSendingNewConversation.asStateFlow() + private val _isSendingMessage = MutableStateFlow(false) + val isSendingMessage: StateFlow = _isSendingMessage.asStateFlow() override fun initRepository(accessToken: String) { heSupportRepository.init(accessToken) @@ -37,7 +37,7 @@ class HESupportViewModel @Inject constructor( attachments: List ) { viewModelScope.launch { - _isSendingNewConversation.value = true + _isSendingMessage.value = true when (val result = heSupportRepository.createConversation( subject = subject, @@ -63,7 +63,7 @@ class HESupportViewModel @Inject constructor( } } - _isSendingNewConversation.value = false + _isSendingMessage.value = false } } @@ -81,7 +81,7 @@ class HESupportViewModel @Inject constructor( return@launch } - _isSendingNewConversation.value = true + _isSendingMessage.value = true when (val result = heSupportRepository.addMessageToConversation( conversationId = selectedConversation.id, @@ -103,7 +103,7 @@ class HESupportViewModel @Inject constructor( } } - _isSendingNewConversation.value = false + _isSendingMessage.value = false } } } 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 ce0a17abc48b..062fcbda757c 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 @@ -63,7 +63,7 @@ class HESupportViewModelTest : BaseUnitTest() { @Test fun `isSendingNewConversation is false initially`() { - assertThat(viewModel.isSendingNewConversation.value).isFalse + assertThat(viewModel.isSendingMessage.value).isFalse } // endregion @@ -204,7 +204,7 @@ class HESupportViewModelTest : BaseUnitTest() { ) advanceUntilIdle() - assertThat(viewModel.isSendingNewConversation.value).isFalse + assertThat(viewModel.isSendingMessage.value).isFalse } // endregion @@ -235,7 +235,7 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() verify(appLogWrapper).e(any(), eq("Error answering a conversation: no conversation selected")) - assertThat(viewModel.isSendingNewConversation.value).isFalse + assertThat(viewModel.isSendingMessage.value).isFalse } @Test @@ -355,7 +355,7 @@ class HESupportViewModelTest : BaseUnitTest() { ) advanceUntilIdle() - assertThat(viewModel.isSendingNewConversation.value).isFalse + assertThat(viewModel.isSendingMessage.value).isFalse } // endregion From 03f00feaef1b9a0eee897a3338634849cadbcb00 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 13:25:52 +0200 Subject: [PATCH 63/81] Send message error UX improvement --- .../he/ui/HEConversationDetailScreen.kt | 36 ++++++++++++------- .../support/he/ui/HESupportActivity.kt | 5 ++- .../support/he/ui/HESupportViewModel.kt | 15 ++++++++ 3 files changed, 43 insertions(+), 13 deletions(-) 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 fb2f58f65b89..6fbd41c4f3fb 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 @@ -64,8 +64,10 @@ fun HEConversationDetailScreen( conversation: SupportConversation, isLoading: Boolean = false, isSendingMessage: Boolean = false, + messageSendResult: HESupportViewModel.MessageSendResult? = null, onBackClick: () -> Unit, - onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit + onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit, + onClearMessageSendResult: () -> Unit = {} ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -155,6 +157,7 @@ fun HEConversationDetailScreen( ReplyBottomSheet( sheetState = sheetState, isSending = isSendingMessage, + messageSendResult = messageSendResult, initialMessageText = draftMessageText, initialIncludeAppLogs = draftIncludeAppLogs, onDismiss = { currentMessage, currentIncludeAppLogs -> @@ -168,10 +171,13 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> + onSendMessage(message, includeAppLogs) + }, + onMessageSentSuccessfully = { // Clear draft after successful send draftMessageText = "" draftIncludeAppLogs = false - onSendMessage(message, includeAppLogs) + onClearMessageSendResult() } ) } @@ -329,28 +335,34 @@ private fun ReplyButton( private fun ReplyBottomSheet( sheetState: androidx.compose.material3.SheetState, isSending: Boolean = false, + messageSendResult: HESupportViewModel.MessageSendResult? = null, initialMessageText: String = "", initialIncludeAppLogs: Boolean = false, onDismiss: (currentMessage: String, currentIncludeAppLogs: Boolean) -> Unit, - onSend: (String, Boolean) -> Unit + onSend: (String, Boolean) -> Unit, + onMessageSentSuccessfully: () -> Unit ) { var messageText by remember { mutableStateOf(initialMessageText) } var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - var wasSending by remember { mutableStateOf(false) } // Close the sheet when sending completes successfully - LaunchedEffect(isSending) { - if (wasSending && !isSending) { - // Sending completed, close the sheet (with empty draft since message was sent) - scope.launch { - sheetState.hide() - }.invokeOnCompletion { + LaunchedEffect(messageSendResult) { + when (messageSendResult) { + is HESupportViewModel.MessageSendResult.Success -> { + // Message sent successfully, close the sheet and clear draft onDismiss("", false) + onMessageSentSuccessfully() + } + is HESupportViewModel.MessageSendResult.Failure -> { + // Message failed to send, draft is saved onDismiss + // The error will be shown via snackbar from the Activity + onDismiss("", false) + } + null -> { + // No result yet, do nothing } } - wasSending = isSending } ModalBottomSheet( 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 31a932dbf1e3..0e27d22fdb3a 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 @@ -129,19 +129,22 @@ class HESupportActivity : AppCompatActivity() { val selectedConversation by viewModel.selectedConversation.collectAsState() val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isSendingMessage by viewModel.isSendingMessage.collectAsState() + val messageSendResult by viewModel.messageSendResult.collectAsState() selectedConversation?.let { conversation -> HEConversationDetailScreen( snackbarHostState = snackbarHostState, conversation = conversation, isLoading = isLoadingConversation, isSendingMessage = isSendingMessage, + messageSendResult = messageSendResult, onBackClick = { viewModel.onBackClick() }, onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( message = message, attachments = emptyList() ) - } + }, + onClearMessageSendResult = { viewModel.clearMessageSendResult() } ) } } 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 eefd2829a3a7..09371a142ffa 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 @@ -24,6 +24,14 @@ class HESupportViewModel @Inject constructor( private val _isSendingMessage = MutableStateFlow(false) val isSendingMessage: StateFlow = _isSendingMessage.asStateFlow() + private val _messageSendResult = MutableStateFlow(null) + val messageSendResult: StateFlow = _messageSendResult.asStateFlow() + + sealed class MessageSendResult { + data object Success : MessageSendResult() + data object Failure : MessageSendResult() + } + override fun initRepository(accessToken: String) { heSupportRepository.init(accessToken) } @@ -90,20 +98,27 @@ class HESupportViewModel @Inject constructor( )) { is CreateConversationResult.Success -> { _selectedConversation.value = result.conversation + _messageSendResult.value = MessageSendResult.Success } is CreateConversationResult.Error.Forbidden -> { _errorMessage.value = ErrorType.FORBIDDEN appLogWrapper.e(AppLog.T.SUPPORT, "Unauthorized error adding message to HE conversation") + _messageSendResult.value = MessageSendResult.Failure } is CreateConversationResult.Error.GeneralError -> { _errorMessage.value = ErrorType.GENERAL appLogWrapper.e(AppLog.T.SUPPORT, "General error adding message to HE conversation") + _messageSendResult.value = MessageSendResult.Failure } } _isSendingMessage.value = false } } + + fun clearMessageSendResult() { + _messageSendResult.value = null + } } From c117fcf570b0b796a9a1c7bcb06e407c0a29ea2b Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 13:36:20 +0200 Subject: [PATCH 64/81] Fixing tests --- .../support/main/ui/SupportViewModel.kt | 2 +- .../ui/ConversationsSupportViewModelTest.kt | 1 - .../he/repository/HESupportRepositoryTest.kt | 33 +++++-------------- .../support/he/ui/HESupportViewModelTest.kt | 22 ------------- .../support/main/ui/SupportViewModelTest.kt | 13 -------- 5 files changed, 9 insertions(+), 62 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index e26b37c25eac..33cf13bc99e2 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -34,7 +34,7 @@ class SupportViewModel @Inject constructor( val showAskHappinessEngineers: Boolean = true ) - private val _userInfo = MutableStateFlow(UserInfo("", "", "")) + private val _userInfo = MutableStateFlow(UserInfo("", "", null)) val userInfo: StateFlow = _userInfo.asStateFlow() private val _optionsVisibility = MutableStateFlow(SupportOptionsVisibility()) 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 0e0d8d6f361a..90bfe32ae9d1 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 @@ -70,7 +70,6 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { advanceUntilIdle() val userInfo = viewModel.userInfo.value - assertThat(userInfo.accessToken).isEqualTo(testAccessToken) assertThat(userInfo.userName).isEqualTo(testUserName) assertThat(userInfo.userEmail).isEqualTo(testUserEmail) assertThat(userInfo.avatarUrl).isEqualTo(testAvatarUrl) 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 c6a6562848d3..21ca6048c9f7 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 @@ -18,6 +18,7 @@ import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.SupportConversationSummary import uniffi.wp_api.SupportMessageAuthor +import uniffi.wp_api.WpErrorCode import java.util.Date @ExperimentalCoroutinesApi @@ -209,12 +210,17 @@ class HESupportRepositoryTest : BaseUnitTest() { } @Test - fun `createConversation returns Unauthorized when request fails with 401`() = runTest { + fun `createConversation returns Forbidden when request fails with WpErrorCode-Forbidden`() = runTest { // Given repository.init(testAccessToken) val errorResponse: WpRequestResult = - WpRequestResult.UnknownError(401.toUShort(), "Unauthorized") + WpRequestResult.WpError( + errorCode = WpErrorCode.Forbidden(), + errorMessage = "Forbidden", + statusCode = 403.toUShort(), + response = "" + ) whenever( wpComApiClient.request(any()) @@ -293,29 +299,6 @@ class HESupportRepositoryTest : BaseUnitTest() { assertThat(successResult.conversation).isEqualTo(supportConversation.toSupportConversation()) } - @Test - fun `addMessageToConversation returns Unauthorized when request fails with 403`() = runTest { - // Given - repository.init(testAccessToken) - - val errorResponse: WpRequestResult = - WpRequestResult.UnknownError(403.toUShort(), "Forbidden") - - whenever( - wpComApiClient.request(any()) - ).thenReturn(errorResponse) - - // When - val result = repository.addMessageToConversation( - conversationId = 456L, - message = "Test", - attachments = emptyList() - ) - - // Then - assertThat(result).isInstanceOf(CreateConversationResult.Error.Forbidden::class.java) - } - @Test fun `addMessageToConversation returns GeneralError when request fails with non-auth error`() = runTest { // Given 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 062fcbda757c..18e3d62962d0 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 @@ -126,28 +126,6 @@ class HESupportViewModelTest : BaseUnitTest() { ) } - @Test - fun `onSendNewConversation calls onConversationClick on success`() = test { - val newConversation = createTestConversation(1) - whenever(heSupportRepository.loadConversation(1L)).thenReturn(newConversation) - whenever(heSupportRepository.createConversation( - subject = "Test Subject", - message = "Test Message", - tags = listOf("tag1"), - attachments = emptyList() - )).thenReturn(CreateConversationResult.Success(newConversation)) - - viewModel.onSendNewConversation( - subject = "Test Subject", - message = "Test Message", - tags = listOf("tag1"), - attachments = emptyList() - ) - advanceUntilIdle() - - assertThat(viewModel.selectedConversation.value).isEqualTo(newConversation) - } - @Test fun `onSendNewConversation sets FORBIDDEN error on Unauthorized result`() = test { whenever(heSupportRepository.createConversation( diff --git a/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt index eafb06bfb639..284fd96f9a7b 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt @@ -171,13 +171,7 @@ class SupportViewModelTest : BaseUnitTest() { @Test fun `onAskTheBotsClick emits NavigateToAskTheBots event when user has access token`() = test { // Given - val accessToken = "test_access_token" - val displayName = "Test User" - whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(accountStore.accessToken).thenReturn(accessToken) - whenever(accountStore.account).thenReturn(account) - whenever(account.displayName).thenReturn(displayName) // When viewModel.navigationEvents.test { @@ -192,14 +186,7 @@ class SupportViewModelTest : BaseUnitTest() { @Test fun `onAskTheBotsClick uses userName when displayName is empty`() = test { // Given - val accessToken = "test_access_token" - val userName = "testuser" - whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(accountStore.accessToken).thenReturn(accessToken) - whenever(accountStore.account).thenReturn(account) - whenever(account.displayName).thenReturn("") - whenever(account.userName).thenReturn(userName) // When viewModel.navigationEvents.test { From d40d1d28d5c983a70e77c2c0d40250abf44fe36b Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 14:09:26 +0200 Subject: [PATCH 65/81] Converting the UI to more AndroidMaterial style --- .../aibot/ui/AIBotConversationsListScreen.kt | 88 ++++----- .../he/ui/HEConversationDetailScreen.kt | 2 +- .../he/ui/HEConversationsListScreen.kt | 103 +++++----- .../android/support/logs/ui/LogsListScreen.kt | 75 +++----- .../android/support/main/ui/SupportScreen.kt | 178 ++++++++---------- 5 files changed, 195 insertions(+), 251 deletions(-) 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 c82247d2c8f4..38b8f4dcdbed 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 @@ -6,7 +6,6 @@ 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 @@ -15,9 +14,8 @@ 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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -31,9 +29,12 @@ 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 @@ -120,70 +121,63 @@ private fun ShowConversationsList( val resources = LocalResources.current LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = modifier.fillMaxSize() ) { - item { - // Add top spacing - Spacer(modifier = Modifier.padding(top = 4.dp)) - } - - items(conversations) { conversation -> - ConversationCard( + items( + items = conversations, + key = { it.id } + ) { conversation -> + ConversationListItem( conversation = conversation, resources = resources, onClick = { onConversationClick(conversation) } ) - } - - item { - // Add bottom spacing - Spacer(modifier = Modifier.padding(bottom = 4.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) } } } @Composable -private fun ConversationCard( +private fun ConversationListItem( 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) + ) } } 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..7e5b19251b5f 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 @@ -236,7 +236,7 @@ private fun ConversationTitleCard(title: String) { text = title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer + color = MaterialTheme.colorScheme.primary ) } } 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..f313c378d5da 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,18 +6,15 @@ 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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -119,28 +116,20 @@ private fun ShowConversationsList( ) } else { LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) + modifier = Modifier.fillMaxSize() ) { - item { - Spacer(modifier = Modifier.height(16.dp)) - } - items( items = conversationsList, key = { it.id } ) { conversation -> - ConversationCard( + ConversationListItem( conversation = conversation, resources = resources, onClick = { onConversationClick(conversation) } ) - Spacer(modifier = Modifier.height(12.dp)) - } - - item { - Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) } } } @@ -148,68 +137,60 @@ private fun ShowConversationsList( } @Composable -private fun ConversationCard( +private fun ConversationListItem( 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) + ) } } 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..4b0b5eeeb3e5 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 @@ -70,27 +66,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) @@ -135,109 +119,108 @@ fun SupportScreen( } else { Button( onClick = onLoginClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp) ) { Text(text = stringResource(R.string.support_screen_login_button)) } } - 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) + ) +} + @Composable private fun SupportOptionItem( title: String, @@ -248,15 +231,16 @@ private fun SupportOptionItem( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(16.dp), + .padding(horizontal = 16.dp, vertical = 16.dp) ) { 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 +251,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 +273,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", From d318c2d35ca227292f3fe3fdee66f8e1f233b0ac Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 14:11:02 +0200 Subject: [PATCH 66/81] Bots screen renaming --- .../aibot/ui/AIBotConversationDetailScreen.kt | 10 +++++----- .../aibot/ui/AIBotConversationsListScreen.kt | 14 +++++++------- .../support/aibot/ui/AIBotSupportActivity.kt | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) 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 c204457cb1ff..c6ee7c9f4848 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 @@ -60,7 +60,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationDetailScreen( +fun AIBotConversationDetailScreen( snackbarHostState: SnackbarHostState, conversation: BotConversation, isLoading: Boolean, @@ -362,7 +362,7 @@ private fun ConversationDetailScreenPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, @@ -382,7 +382,7 @@ private fun ConversationDetailScreenPreviewDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, @@ -402,7 +402,7 @@ private fun ConversationDetailScreenWordPressPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, @@ -422,7 +422,7 @@ private fun ConversationDetailScreenPreviewWordPressDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, 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 c82247d2c8f4..e5664db6b7a2 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 @@ -49,7 +49,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationsListScreen( +fun AIBotConversationsListScreen( snackbarHostState: SnackbarHostState, conversations: StateFlow>, isLoading: Boolean, @@ -194,7 +194,7 @@ private fun ConversationsScreenPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = false, @@ -213,7 +213,7 @@ private fun ConversationsScreenPreviewDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = false, @@ -232,7 +232,7 @@ private fun ConversationsScreenWordPressPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = true, @@ -251,7 +251,7 @@ private fun ConversationsScreenPreviewWordPressDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = true, @@ -270,7 +270,7 @@ private fun EmptyConversationsScreenPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = emptyConversations.asStateFlow(), isLoading = false, @@ -289,7 +289,7 @@ private fun EmptyConversationsScreenPreviewDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = emptyConversations.asStateFlow(), isLoading = false, 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 de6e0ac3a8fd..79f2bde12052 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 @@ -110,7 +110,7 @@ class AIBotSupportActivity : AppCompatActivity() { ) { composable(route = ConversationScreen.List.name) { val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = viewModel.conversations, isLoading = isLoadingConversations, @@ -134,7 +134,7 @@ class AIBotSupportActivity : AppCompatActivity() { val canSendMessage by viewModel.canSendMessage.collectAsState() val userInfo by viewModel.userInfo.collectAsState() selectedConversation?.let { conversation -> - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = userInfo.userName, conversation = conversation, From c12f0fd89d5825385aae1a73622d89e60c082889 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 23 Oct 2025 14:11:45 +0200 Subject: [PATCH 67/81] Bots screens renaming --- .../aibot/ui/AIBotConversationDetailScreen.kt | 10 +++++----- .../aibot/ui/AIBotConversationsListScreen.kt | 15 +++++++-------- .../support/aibot/ui/AIBotSupportActivity.kt | 4 ++-- 3 files changed, 14 insertions(+), 15 deletions(-) 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 c204457cb1ff..c6ee7c9f4848 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 @@ -60,7 +60,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationDetailScreen( +fun AIBotConversationDetailScreen( snackbarHostState: SnackbarHostState, conversation: BotConversation, isLoading: Boolean, @@ -362,7 +362,7 @@ private fun ConversationDetailScreenPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, @@ -382,7 +382,7 @@ private fun ConversationDetailScreenPreviewDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, @@ -402,7 +402,7 @@ private fun ConversationDetailScreenWordPressPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, @@ -422,7 +422,7 @@ private fun ConversationDetailScreenPreviewWordPressDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = "UserName", conversation = sampleConversation, 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 38b8f4dcdbed..57752a0fb9ee 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,7 +3,6 @@ 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.fillMaxSize @@ -50,7 +49,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConversationsListScreen( +fun AIBotConversationsListScreen( snackbarHostState: SnackbarHostState, conversations: StateFlow>, isLoading: Boolean, @@ -188,7 +187,7 @@ private fun ConversationsScreenPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = false, @@ -207,7 +206,7 @@ private fun ConversationsScreenPreviewDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = false, @@ -226,7 +225,7 @@ private fun ConversationsScreenWordPressPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = true, @@ -245,7 +244,7 @@ private fun ConversationsScreenPreviewWordPressDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = sampleConversations.asStateFlow(), isLoading = true, @@ -264,7 +263,7 @@ private fun EmptyConversationsScreenPreview() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = emptyConversations.asStateFlow(), isLoading = false, @@ -283,7 +282,7 @@ private fun EmptyConversationsScreenPreviewDark() { val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = emptyConversations.asStateFlow(), isLoading = false, 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 de6e0ac3a8fd..79f2bde12052 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 @@ -110,7 +110,7 @@ class AIBotSupportActivity : AppCompatActivity() { ) { composable(route = ConversationScreen.List.name) { val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() - ConversationsListScreen( + AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = viewModel.conversations, isLoading = isLoadingConversations, @@ -134,7 +134,7 @@ class AIBotSupportActivity : AppCompatActivity() { val canSendMessage by viewModel.canSendMessage.collectAsState() val userInfo by viewModel.userInfo.collectAsState() selectedConversation?.let { conversation -> - ConversationDetailScreen( + AIBotConversationDetailScreen( snackbarHostState = snackbarHostState, userName = userInfo.userName, conversation = conversation, From 1d4a490deed3a3ad6dbb3bf21e1ef7973e2b74b1 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 24 Oct 2025 10:45:51 +0200 Subject: [PATCH 68/81] Make NewTicket screen more Android Material theme as well --- .../support/he/ui/HENewTicketScreen.kt | 290 ++++++++++-------- .../support/he/ui/TicketMainContentView.kt | 88 +++--- 2 files changed, 209 insertions(+), 169 deletions(-) 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..a9d180dc25c5 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 @@ -101,16 +102,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,20 +118,14 @@ 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)) Text( text = stringResource(R.string.he_support_subject_label), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) ) @@ -145,21 +135,22 @@ fun HENewTicketScreen( modifier = Modifier.fillMaxWidth(), 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)) Text( text = stringResource(R.string.he_support_site_address_label), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) ) @@ -169,12 +160,14 @@ fun HENewTicketScreen( modifier = Modifier.fillMaxWidth(), 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.Sentences), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) ) Spacer(modifier = Modifier.height(32.dp)) @@ -188,12 +181,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 +189,61 @@ 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) + ) +} + @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(20.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 +255,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 +311,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 +333,63 @@ private fun CategoryOption( isSelected: Boolean, onClick: () -> Unit ) { - Row( - 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) + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + ), + border = if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + 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.onPrimaryContainer + } else { + MaterialTheme.colorScheme.primary + }, + 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 = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + 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/TicketMainContentView.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt index ac4b8fb3f129..48e583104cde 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,6 @@ 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.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,10 +13,13 @@ 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 @@ -47,8 +49,8 @@ fun TicketMainContentView( ) { Text( text = stringResource(R.string.he_support_message_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp) ) @@ -60,14 +62,17 @@ fun TicketMainContentView( .height(200.dp), 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) ) @@ -81,7 +86,9 @@ fun TicketMainContentView( Button( onClick = { /* Placeholder for add screenshots */ }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), shape = RoundedCornerShape(12.dp), enabled = enabled ) { @@ -93,7 +100,7 @@ fun TicketMainContentView( Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(R.string.he_support_add_screenshots_button), - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.labelLarge ) } @@ -101,44 +108,51 @@ 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) ) - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + 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 = stringResource(R.string.he_support_include_logs_title), + 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 + Text( + text = stringResource(R.string.he_support_include_logs_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.size(16.dp)) + + Switch( + checked = includeAppLogs, + onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) }, + enabled = enabled ) } - - Switch( - checked = includeAppLogs, - onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) }, - enabled = enabled - ) } } } From 7232fb270486fe7149449852d8dcf1ea379780f8 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 24 Oct 2025 11:47:03 +0200 Subject: [PATCH 69/81] Adding preview for EmptyConversationsView --- .../common/ui/EmptyConversationsView.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) 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 = { } + ) + } +} From a6e3e65f58db82ca79eef7e77f37ff8a46a9999c Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 24 Oct 2025 11:59:46 +0200 Subject: [PATCH 70/81] Button fix --- .../support/he/ui/TicketMainContentView.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 48e583104cde..e750e6fe71e1 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,6 +1,7 @@ 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.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,6 +31,8 @@ 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 @@ -84,16 +87,24 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) - Button( + OutlinedButton( onClick = { /* Placeholder for add screenshots */ }, modifier = Modifier .fillMaxWidth() .height(48.dp), 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) ) From 19fcdf63ef23c601cb8c11e96dafce536653a634 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 24 Oct 2025 14:05:43 +0200 Subject: [PATCH 71/81] detekt --- .../wordpress/android/support/he/ui/TicketMainContentView.kt | 2 -- 1 file changed, 2 deletions(-) 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 e750e6fe71e1..93a75cb08257 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 @@ -12,8 +12,6 @@ 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 From 6ddffcff70b2210dd90f42ebe1a53f0d2b40fcb5 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 10:02:37 +0100 Subject: [PATCH 72/81] Ticket selection change --- .../support/he/ui/HENewTicketScreen.kt | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) 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 1c2a4272dc45..0de44b65e38a 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 @@ -339,17 +339,16 @@ private fun CategoryOption( onClick = onClick, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) { + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colorScheme.surfaceContainerHigh + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) } ), - border = if (isSelected) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, elevation = CardDefaults.cardElevation( defaultElevation = 0.dp ) @@ -361,10 +360,10 @@ private fun CategoryOption( Icon( imageVector = icon, contentDescription = null, - tint = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { + tint = if (isSelected) { MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) }, modifier = Modifier.size(24.dp) ) @@ -372,11 +371,7 @@ private fun CategoryOption( Text( text = label, style = MaterialTheme.typography.bodyLarge, - color = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .weight(1f) .padding(horizontal = 16.dp) From 30fb83f9a7ca6034bc66ea542a2d4f0900523a7a Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 10:15:57 +0100 Subject: [PATCH 73/81] Supporting markdown text --- .../aibot/ui/AIBotConversationDetailScreen.kt | 3 +- .../he/ui/HEConversationDetailScreen.kt | 3 +- .../android/ui/compose/utils/MarkdownUtils.kt | 92 +++++++ .../ui/compose/utils/MarkdownUtilsTest.kt | 230 ++++++++++++++++++ 4 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/compose/utils/MarkdownUtils.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/compose/utils/MarkdownUtilsTest.kt 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..85aeac4b3c39 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 @@ -57,6 +57,7 @@ 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.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -274,7 +275,7 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re ) { Column { Text( - text = message.text, + text = markdownToAnnotatedString(message.text), style = MaterialTheme.typography.bodyMedium, color = if (message.isWrittenByUser) { MaterialTheme.colorScheme.onPrimaryContainer 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 7e5b19251b5f..6214b6fbd3a8 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 @@ -56,6 +56,7 @@ import org.wordpress.android.support.he.util.generateSampleHESupportConversation import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -290,7 +291,7 @@ private fun MessageItem( Spacer(modifier = Modifier.height(8.dp)) Text( - text = messageText, + text = markdownToAnnotatedString(messageText), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) 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..c99092236688 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/MarkdownUtils.kt @@ -0,0 +1,92 @@ +package org.wordpress.android.ui.compose.utils + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration + +/** + * Convert markdown text to Compose AnnotatedString. + * Supports basic markdown formatting: bold, italic, bold+italic, and inline code. + */ +fun markdownToAnnotatedString(markdownText: String): AnnotatedString = buildAnnotatedString { + var currentIndex = 0 + val text = markdownText + + while (currentIndex < text.length) { + when { + // Bold + Italic: ***text*** or ___text___ + text.startsWith("***", currentIndex) || text.startsWith("___", currentIndex) -> { + val delimiter = text.substring(currentIndex, currentIndex + 3) + val endIndex = text.indexOf(delimiter, currentIndex + 3) + if (endIndex != -1) { + val start = length + append(text.substring(currentIndex + 3, endIndex)) + addStyle( + SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), + start, + length + ) + currentIndex = endIndex + 3 + } else { + append(text[currentIndex]) + currentIndex++ + } + } + // Bold: **text** or __text__ + text.startsWith("**", currentIndex) || text.startsWith("__", currentIndex) -> { + val delimiter = text.substring(currentIndex, currentIndex + 2) + val endIndex = text.indexOf(delimiter, currentIndex + 2) + if (endIndex != -1 && endIndex > currentIndex + 2) { + val start = length + append(text.substring(currentIndex + 2, endIndex)) + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, length) + currentIndex = endIndex + 2 + } else { + append(text[currentIndex]) + currentIndex++ + } + } + // Italic: *text* or _text_ + text[currentIndex] == '*' || text[currentIndex] == '_' -> { + val delimiter = text[currentIndex] + val endIndex = text.indexOf(delimiter, currentIndex + 1) + if (endIndex != -1 && endIndex != currentIndex + 1) { + val start = length + append(text.substring(currentIndex + 1, endIndex)) + addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, length) + currentIndex = endIndex + 1 + } else { + append(text[currentIndex]) + currentIndex++ + } + } + // Inline code: `text` + text[currentIndex] == '`' -> { + val endIndex = text.indexOf('`', currentIndex + 1) + if (endIndex != -1) { + val start = length + append(text.substring(currentIndex + 1, endIndex)) + addStyle( + SpanStyle( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + background = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.2f) + ), + start, + length + ) + currentIndex = endIndex + 1 + } else { + append(text[currentIndex]) + currentIndex++ + } + } + else -> { + append(text[currentIndex]) + currentIndex++ + } + } + } +} 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..3183a40bd9f6 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/compose/utils/MarkdownUtilsTest.kt @@ -0,0 +1,230 @@ +package org.wordpress.android.ui.compose.utils + +import androidx.compose.ui.text.SpanStyle +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") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].item.fontStyle).isEqualTo(FontStyle.Italic) + assertThat(result.spanStyles[0].start).isEqualTo(8) + assertThat(result.spanStyles[0].end).isEqualTo(23) + } + + @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") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(result.spanStyles[0].item.fontStyle).isEqualTo(FontStyle.Italic) + assertThat(result.spanStyles[0].start).isEqualTo(8) + assertThat(result.spanStyles[0].end).isEqualTo(23) + } + + @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 not supported and treated literally`() { + val input = "**bold *and italic* combined**" + val result = markdownToAnnotatedString(input) + + // The outer bold will be applied to "bold *and italic* combined" + assertThat(result.text).isEqualTo("bold *and italic* combined") + assertThat(result.spanStyles).hasSize(1) + assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + } + + @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) + } +} From 3f908f2c3aab3b2253e90c521d7b9c7ff221d244 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 10:27:35 +0100 Subject: [PATCH 74/81] detekt --- .../android/ui/compose/utils/MarkdownUtils.kt | 132 ++++++++++-------- .../ui/compose/utils/MarkdownUtilsTest.kt | 1 - 2 files changed, 77 insertions(+), 56 deletions(-) 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 index c99092236688..962976c52a9e 100644 --- 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 @@ -1,11 +1,17 @@ package org.wordpress.android.ui.compose.utils +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString 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 + +private const val TRIPLE_DELIMITER_LENGTH = 3 +private const val DOUBLE_DELIMITER_LENGTH = 2 +private const val SINGLE_DELIMITER_LENGTH = 1 +private const val CODE_BACKGROUND_ALPHA = 0.2f /** * Convert markdown text to Compose AnnotatedString. @@ -19,69 +25,19 @@ fun markdownToAnnotatedString(markdownText: String): AnnotatedString = buildAnno when { // Bold + Italic: ***text*** or ___text___ text.startsWith("***", currentIndex) || text.startsWith("___", currentIndex) -> { - val delimiter = text.substring(currentIndex, currentIndex + 3) - val endIndex = text.indexOf(delimiter, currentIndex + 3) - if (endIndex != -1) { - val start = length - append(text.substring(currentIndex + 3, endIndex)) - addStyle( - SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), - start, - length - ) - currentIndex = endIndex + 3 - } else { - append(text[currentIndex]) - currentIndex++ - } + currentIndex = processBoldItalic(text, currentIndex) } // Bold: **text** or __text__ text.startsWith("**", currentIndex) || text.startsWith("__", currentIndex) -> { - val delimiter = text.substring(currentIndex, currentIndex + 2) - val endIndex = text.indexOf(delimiter, currentIndex + 2) - if (endIndex != -1 && endIndex > currentIndex + 2) { - val start = length - append(text.substring(currentIndex + 2, endIndex)) - addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, length) - currentIndex = endIndex + 2 - } else { - append(text[currentIndex]) - currentIndex++ - } + currentIndex = processBold(text, currentIndex) } // Italic: *text* or _text_ text[currentIndex] == '*' || text[currentIndex] == '_' -> { - val delimiter = text[currentIndex] - val endIndex = text.indexOf(delimiter, currentIndex + 1) - if (endIndex != -1 && endIndex != currentIndex + 1) { - val start = length - append(text.substring(currentIndex + 1, endIndex)) - addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, length) - currentIndex = endIndex + 1 - } else { - append(text[currentIndex]) - currentIndex++ - } + currentIndex = processItalic(text, currentIndex) } // Inline code: `text` text[currentIndex] == '`' -> { - val endIndex = text.indexOf('`', currentIndex + 1) - if (endIndex != -1) { - val start = length - append(text.substring(currentIndex + 1, endIndex)) - addStyle( - SpanStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - background = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.2f) - ), - start, - length - ) - currentIndex = endIndex + 1 - } else { - append(text[currentIndex]) - currentIndex++ - } + currentIndex = processInlineCode(text, currentIndex) } else -> { append(text[currentIndex]) @@ -90,3 +46,69 @@ fun markdownToAnnotatedString(markdownText: String): AnnotatedString = buildAnno } } } + +private fun AnnotatedString.Builder.processBoldItalic(text: String, startIndex: Int): Int { + val delimiter = text.substring(startIndex, startIndex + TRIPLE_DELIMITER_LENGTH) + val endIndex = text.indexOf(delimiter, startIndex + TRIPLE_DELIMITER_LENGTH) + return if (endIndex != -1) { + val start = length + append(text.substring(startIndex + TRIPLE_DELIMITER_LENGTH, endIndex)) + addStyle( + SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), + start, + length + ) + endIndex + TRIPLE_DELIMITER_LENGTH + } else { + append(text[startIndex]) + startIndex + SINGLE_DELIMITER_LENGTH + } +} + +private fun AnnotatedString.Builder.processBold(text: String, startIndex: Int): Int { + val delimiter = text.substring(startIndex, startIndex + DOUBLE_DELIMITER_LENGTH) + val endIndex = text.indexOf(delimiter, startIndex + DOUBLE_DELIMITER_LENGTH) + return if (endIndex != -1 && endIndex > startIndex + DOUBLE_DELIMITER_LENGTH) { + val start = length + append(text.substring(startIndex + DOUBLE_DELIMITER_LENGTH, endIndex)) + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, length) + endIndex + DOUBLE_DELIMITER_LENGTH + } else { + append(text[startIndex]) + startIndex + SINGLE_DELIMITER_LENGTH + } +} + +private fun AnnotatedString.Builder.processItalic(text: String, startIndex: Int): Int { + val delimiter = text[startIndex] + val endIndex = text.indexOf(delimiter, startIndex + SINGLE_DELIMITER_LENGTH) + return if (endIndex != -1 && endIndex != startIndex + SINGLE_DELIMITER_LENGTH) { + val start = length + append(text.substring(startIndex + SINGLE_DELIMITER_LENGTH, endIndex)) + addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, length) + endIndex + SINGLE_DELIMITER_LENGTH + } else { + append(text[startIndex]) + startIndex + SINGLE_DELIMITER_LENGTH + } +} + +private fun AnnotatedString.Builder.processInlineCode(text: String, startIndex: Int): Int { + val endIndex = text.indexOf('`', startIndex + SINGLE_DELIMITER_LENGTH) + return if (endIndex != -1) { + val start = length + append(text.substring(startIndex + SINGLE_DELIMITER_LENGTH, endIndex)) + addStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = Color.Gray.copy(alpha = CODE_BACKGROUND_ALPHA) + ), + start, + length + ) + endIndex + SINGLE_DELIMITER_LENGTH + } else { + append(text[startIndex]) + startIndex + SINGLE_DELIMITER_LENGTH + } +} 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 index 3183a40bd9f6..2abe384921a0 100644 --- 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 @@ -1,6 +1,5 @@ package org.wordpress.android.ui.compose.utils -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight From 1f6f555768cadf624718ba8ff7c3e993fda0b207 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 10:54:14 +0100 Subject: [PATCH 75/81] Improving MarkdownUtils --- .../android/ui/compose/utils/MarkdownUtils.kt | 38 +++++- .../ui/compose/utils/MarkdownUtilsTest.kt | 121 ++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) 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 index 962976c52a9e..691fe326cf41 100644 --- 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 @@ -14,8 +14,31 @@ private const val SINGLE_DELIMITER_LENGTH = 1 private const val CODE_BACKGROUND_ALPHA = 0.2f /** - * Convert markdown text to Compose AnnotatedString. - * Supports basic markdown formatting: bold, italic, bold+italic, and inline code. + * Convert markdown text to Compose AnnotatedString with basic formatting support. + * + * ## Supported Syntax + * - **Bold**: `**text**` or `__text__` + * - *Italic*: `*text*` or `_text_` + * - ***Bold + Italic***: `***text***` or `___text___` + * - `Inline Code`: `` `text` `` + * + * ## Limitations + * - Nested formatting is not supported (e.g., `**bold *and italic***` will only apply bold to the outer content) + * - Mixed delimiters are not supported (e.g., `**bold__` won't work, use matching delimiters) + * - Multiline formatting is supported but not optimized for very long texts (>10,000 characters) + * - Links, images, lists, headers, and block quotes are not supported + * + * ## Escape Characters + * Use backslash `\` to escape markdown characters: + * - `\*not italic\*` → *not italic* (literal asterisks) + * - `\`not code\`` → `not code` (literal backticks) + * + * ## Security + * This parser only applies text styling and does not interpret URLs, HTML, or scripts. + * 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 */ fun markdownToAnnotatedString(markdownText: String): AnnotatedString = buildAnnotatedString { var currentIndex = 0 @@ -23,6 +46,17 @@ fun markdownToAnnotatedString(markdownText: String): AnnotatedString = buildAnno while (currentIndex < text.length) { when { + // Escape character: \* → * + text[currentIndex] == '\\' && currentIndex + SINGLE_DELIMITER_LENGTH < text.length -> { + val nextChar = text[currentIndex + SINGLE_DELIMITER_LENGTH] + if (nextChar in setOf('*', '_', '`', '\\')) { + append(nextChar) + currentIndex += DOUBLE_DELIMITER_LENGTH + } else { + append(text[currentIndex]) + currentIndex++ + } + } // Bold + Italic: ***text*** or ___text___ text.startsWith("***", currentIndex) || text.startsWith("___", currentIndex) -> { currentIndex = processBoldItalic(text, currentIndex) 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 index 2abe384921a0..25a32ee2af72 100644 --- 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 @@ -226,4 +226,125 @@ class MarkdownUtilsTest { 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) + + assertThat(result.text).isEqualTo("Line 1 bold\nLine 2 italic\nLine 3 normal") + 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 `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() + } } From 727644cfc97032f2c4a22fb8b15b9a9e19d8d0eb Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 11:33:32 +0100 Subject: [PATCH 76/81] Formatting text in the repository layer instead the ui --- .../android/support/aibot/model/BotMessage.kt | 4 + .../repository/AIBotSupportRepository.kt | 2 + .../aibot/ui/AIBotConversationDetailScreen.kt | 3 +- .../support/aibot/ui/AIBotSupportViewModel.kt | 6 +- .../support/aibot/util/ConversationUtils.kt | 114 ++++-------------- .../common/ui/EmptyConversationsView.kt | 1 - .../support/he/model/SupportMessage.kt | 4 + .../he/repository/HESupportRepository.kt | 2 + .../he/ui/HEConversationDetailScreen.kt | 24 ++-- .../support/he/util/HEConversationUtils.kt | 19 ++- .../android/ui/compose/utils/MarkdownUtils.kt | 2 +- 11 files changed, 66 insertions(+), 115 deletions(-) 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..1ff920c53555 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 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..f42264e43209 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 @@ -166,6 +167,7 @@ class AIBotSupportRepository @Inject constructor( BotMessage( id = messageId.toLong(), text = 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 85aeac4b3c39..50c731d272ac 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 @@ -57,7 +57,6 @@ 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.ui.compose.theme.AppThemeM3 -import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -275,7 +274,7 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re ) { Column { Text( - text = markdownToAnnotatedString(message.text), + text = message.formattedText, style = MaterialTheme.typography.bodyMedium, color = if (message.isWrittenByUser) { MaterialTheme.colorScheme.onPrimaryContainer 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..29620868be86 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 @@ -12,6 +12,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.ui.compose.utils.markdownToAnnotatedString import org.wordpress.android.util.AppLog import java.util.Date import javax.inject.Inject @@ -65,13 +66,14 @@ class AIBotSupportViewModel @Inject constructor( _canSendMessage.value = false val now = Date() - val userMessage = BotMessage( + val botMessage = BotMessage( id = System.currentTimeMillis(), text = 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 ) 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..bf974eed30b1 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,48 @@ 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?", + text = "", + 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 " + + text = "", + 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.", + text = "", + 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 " + + text = "", + 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 " + + text = "" + "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 +118,18 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 2001, - text = "I just created my WordPress site and need help getting started. Where should I begin?", + text = "", + 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 " + + text = "", + 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 +145,21 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 3001, - text = "How can I change the colors on my site? I want to match my brand.", + text = "", + 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. " + + text = "", + 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/EmptyConversationsView.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/EmptyConversationsView.kt index 75b24feeb744..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 @@ -20,7 +20,6 @@ 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 -import androidx.compose.ui.unit.dp @Composable fun EmptyConversationsView( 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..2e5f03641a29 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 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..856eb6c0fe7a 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 @@ -195,6 +196,7 @@ class HESupportRepository @Inject constructor( SupportMessage( id = this.id.toLong(), text = 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 6214b6fbd3a8..28f4b3a42dbb 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 @@ -52,11 +52,11 @@ import androidx.compose.ui.unit.dp 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 import org.wordpress.android.ui.compose.theme.AppThemeM3 -import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -134,10 +134,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) ) } @@ -244,16 +242,14 @@ private fun ConversationTitleCard(title: String) { @Composable private fun MessageItem( - authorName: String, - messageText: String, - timestamp: String, - isUserMessage: Boolean + message: SupportMessage, + timestamp: String ) { Box( modifier = Modifier .fillMaxWidth() .background( - color = if (isUserMessage) { + color = if (message.authorIsUser) { MaterialTheme.colorScheme.primary.copy(alpha = 0.20f) } else { MaterialTheme.colorScheme.surfaceVariant @@ -271,10 +267,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 @@ -291,7 +287,7 @@ private fun MessageItem( Spacer(modifier = Modifier.height(8.dp)) Text( - text = markdownToAnnotatedString(messageText), + text = message.formattedText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) 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..8fac9bd65155 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,24 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 1, - text = "Hello! My website has been loading very slowly for the past few days.", + text = "", + 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?", + text = "", + 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", + text = "", + formattedText = AnnotatedString("Sure, it's example.wordpress.com"), createdAt = oneHourAgo, authorName = "You", authorIsUser = true @@ -52,14 +56,16 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 4, - text = "I'm trying to install a new plugin but getting an error.", + text = "", + 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?", + text = "", + formattedText = AnnotatedString("I can help with that! What's the error message you're seeing?"), createdAt = twoDaysAgo, authorName = "Support Agent", authorIsUser = false @@ -76,7 +82,8 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 6, - text = "I need help setting up my custom domain.", + text = "", + 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/ui/compose/utils/MarkdownUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/MarkdownUtils.kt index 691fe326cf41..f47154de571a 100644 --- 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 @@ -84,7 +84,7 @@ fun markdownToAnnotatedString(markdownText: String): AnnotatedString = buildAnno private fun AnnotatedString.Builder.processBoldItalic(text: String, startIndex: Int): Int { val delimiter = text.substring(startIndex, startIndex + TRIPLE_DELIMITER_LENGTH) val endIndex = text.indexOf(delimiter, startIndex + TRIPLE_DELIMITER_LENGTH) - return if (endIndex != -1) { + return if (endIndex != -1 && endIndex > startIndex + TRIPLE_DELIMITER_LENGTH) { val start = length append(text.substring(startIndex + TRIPLE_DELIMITER_LENGTH, endIndex)) addStyle( From 3eb939c3ab029559cc6ed56b086d704a6d443d1c Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 11:36:18 +0100 Subject: [PATCH 77/81] Renaming --- .../android/support/aibot/model/BotMessage.kt | 2 +- .../repository/AIBotSupportRepository.kt | 2 +- .../support/aibot/ui/AIBotSupportViewModel.kt | 4 +- .../support/aibot/util/ConversationUtils.kt | 45 +++++++++++-------- .../support/he/model/SupportMessage.kt | 2 +- .../he/repository/HESupportRepository.kt | 2 +- .../support/he/util/HEConversationUtils.kt | 18 ++++---- .../repository/AIBotSupportRepositoryTest.kt | 12 ++--- .../aibot/ui/AIBotSupportViewModelTest.kt | 8 ++-- .../he/repository/HESupportRepositoryTest.kt | 2 +- .../support/he/ui/HESupportViewModelTest.kt | 2 +- 11 files changed, 55 insertions(+), 44 deletions(-) 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 1ff920c53555..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 @@ -7,7 +7,7 @@ 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 f42264e43209..98578659236b 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 @@ -166,7 +166,7 @@ class AIBotSupportRepository @Inject constructor( 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/AIBotSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt index 29620868be86..7f750e574930 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 @@ -68,7 +68,7 @@ class AIBotSupportViewModel @Inject constructor( val now = Date() val botMessage = BotMessage( id = System.currentTimeMillis(), - text = message, + rawText = message, formattedText = markdownToAnnotatedString(message), date = now, isWrittenByUser = true @@ -85,7 +85,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 bf974eed30b1..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 @@ -64,16 +64,18 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 1001, - text = "", - formattedText = AnnotatedString("Hi, I'm having trouble with the app. It keeps crashing when I try to open it after " + + 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 = "", - formattedText = AnnotatedString("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?"), date = Date(now.time - 3_540_000), // 59 minutes ago @@ -81,15 +83,17 @@ fun generateSampleBotConversations(): List { ), BotMessage( id = 1003, - text = "", - formattedText = AnnotatedString("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 = "", - formattedText = AnnotatedString("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 " + @@ -99,9 +103,10 @@ fun generateSampleBotConversations(): List { ), BotMessage( id = 1005, - text = "" + + 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 " + + 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 @@ -118,15 +123,17 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 2001, - text = "", - formattedText = AnnotatedString("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 = "", - formattedText = AnnotatedString("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?"), @@ -145,15 +152,17 @@ fun generateSampleBotConversations(): List { messages = listOf( BotMessage( id = 3001, - text = "", - formattedText = AnnotatedString("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 = "", - formattedText = AnnotatedString("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?"), date = Date(now.time - 86_400_000), 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 2e5f03641a29..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 @@ -7,7 +7,7 @@ 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, 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 856eb6c0fe7a..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 @@ -195,7 +195,7 @@ 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) { 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 8fac9bd65155..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 @@ -22,23 +22,25 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 1, - text = "", - formattedText = AnnotatedString("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 = "", - formattedText = AnnotatedString("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 = "", + rawText = "", formattedText = AnnotatedString("Sure, it's example.wordpress.com"), createdAt = oneHourAgo, authorName = "You", @@ -56,7 +58,7 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 4, - text = "", + rawText = "", formattedText = AnnotatedString("I'm trying to install a new plugin but getting an error."), createdAt = Date(twoDaysAgo.time - 3600000), authorName = "You", @@ -64,7 +66,7 @@ fun generateSampleHESupportConversations(): List { ), SupportMessage( id = 5, - text = "", + rawText = "", formattedText = AnnotatedString("I can help with that! What's the error message you're seeing?"), createdAt = twoDaysAgo, authorName = "Support Agent", @@ -82,7 +84,7 @@ fun generateSampleHESupportConversations(): List { messages = listOf( SupportMessage( id = 6, - text = "", + rawText = "", formattedText = AnnotatedString("I need help setting up my custom domain."), createdAt = oneWeekAgo, authorName = "You", 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..3b1ee28da46a 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 @@ -128,7 +128,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 +245,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 +387,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { ): BotMessage { return BotMessage( id = id, - text = text, + rawText = text, date = Date(), isWrittenByUser = isWrittenByUser ) 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..6d796c9e7164 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 @@ -364,7 +364,7 @@ class HESupportRepositoryTest : BaseUnitTest() { private fun uniffi.wp_api.SupportMessage.toSupportMessage(): SupportMessage = SupportMessage( id = this.id.toLong(), - text = this.content, + rawText = 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..39fce0d8697e 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 @@ -360,7 +360,7 @@ class HESupportViewModelTest : BaseUnitTest() { ): SupportMessage { return SupportMessage( id = id, - text = text, + rawText = text, createdAt = Date(), authorName = if (authorIsUser) "User" else "Support", authorIsUser = authorIsUser From 455c100e5d3d60379132b70842f35a5a313e3264 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 12:00:34 +0100 Subject: [PATCH 78/81] Fixing tests --- .../android/support/aibot/ui/AIBotSupportViewModelTest.kt | 2 ++ .../android/support/he/repository/HESupportRepositoryTest.kt | 2 ++ .../wordpress/android/support/he/ui/HESupportViewModelTest.kt | 2 ++ 3 files changed, 6 insertions(+) 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 3b1ee28da46a..ff35ecce2881 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 @@ -388,6 +389,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { return BotMessage( id = id, rawText = text, + formattedText = AnnotatedString(text), date = Date(), isWrittenByUser = isWrittenByUser ) 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 6d796c9e7164..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 @@ -365,6 +366,7 @@ class HESupportRepositoryTest : BaseUnitTest() { SupportMessage( id = this.id.toLong(), 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 39fce0d8697e..6497ec6dabd4 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 @@ -361,6 +362,7 @@ class HESupportViewModelTest : BaseUnitTest() { return SupportMessage( id = id, rawText = text, + formattedText = AnnotatedString(text), createdAt = Date(), authorName = if (authorIsUser) "User" else "Support", authorIsUser = authorIsUser From 4744542ce1b306e7dbc9925d1d03e6f8bacb72eb Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 17:59:52 +0100 Subject: [PATCH 79/81] Parsing markdown more exhaustively --- WordPress/build.gradle | 1 + .../android/ui/compose/utils/MarkdownUtils.kt | 237 +++++----- .../ui/compose/utils/MarkdownUtilsTest.kt | 407 +++++++++++++++++- gradle/libs.versions.toml | 2 + 4 files changed, 516 insertions(+), 131 deletions(-) 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/ui/compose/utils/MarkdownUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/MarkdownUtils.kt index f47154de571a..08179552ce8e 100644 --- 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 @@ -7,142 +7,155 @@ 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 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 TRIPLE_DELIMITER_LENGTH = 3 -private const val DOUBLE_DELIMITER_LENGTH = 2 -private const val SINGLE_DELIMITER_LENGTH = 1 private const val CODE_BACKGROUND_ALPHA = 0.2f +private const val URL_TAG = "URL" /** - * Convert markdown text to Compose AnnotatedString with basic formatting support. + * Convert markdown text to Compose AnnotatedString using the CommonMark library. * - * ## Supported Syntax + * 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 * - * ## Limitations - * - Nested formatting is not supported (e.g., `**bold *and italic***` will only apply bold to the outer content) - * - Mixed delimiters are not supported (e.g., `**bold__` won't work, use matching delimiters) - * - Multiline formatting is supported but not optimized for very long texts (>10,000 characters) - * - Links, images, lists, headers, and block quotes are not supported + * ## 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. * - * ## Escape Characters - * Use backslash `\` to escape markdown characters: - * - `\*not italic\*` → *not italic* (literal asterisks) - * - `\`not code\`` → `not code` (literal backticks) + * ## List Handling + * Unordered list items are prefixed with "• " (bullet point). List formatting is preserved + * with proper indentation and spacing. + * + * ## Link Handling + * Links are styled with underline and color, and include URL annotations that can be + * used with ClickableText to handle clicks. The URL is stored as a string annotation + * with the tag "URL". * * ## Security - * This parser only applies text styling and does not interpret URLs, HTML, or scripts. + * This parser applies text styling and link annotations. Links are annotated but not + * automatically opened - the calling code must handle URL clicks and validate URLs. * 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 + * @return AnnotatedString with applied formatting styles and link annotations */ -fun markdownToAnnotatedString(markdownText: String): AnnotatedString = buildAnnotatedString { - var currentIndex = 0 - val text = markdownText +fun markdownToAnnotatedString(markdownText: String): AnnotatedString { + val parser = Parser.builder().build() + val document = parser.parse(markdownText) - while (currentIndex < text.length) { - when { - // Escape character: \* → * - text[currentIndex] == '\\' && currentIndex + SINGLE_DELIMITER_LENGTH < text.length -> { - val nextChar = text[currentIndex + SINGLE_DELIMITER_LENGTH] - if (nextChar in setOf('*', '_', '`', '\\')) { - append(nextChar) - currentIndex += DOUBLE_DELIMITER_LENGTH - } else { - append(text[currentIndex]) - currentIndex++ - } + return buildAnnotatedString { + processNode(document) + } +} + +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 + ) } - // Bold + Italic: ***text*** or ___text___ - text.startsWith("***", currentIndex) || text.startsWith("___", currentIndex) -> { - currentIndex = processBoldItalic(text, currentIndex) + is Link -> { + val start = length + processNode(child) + addStyle( + SpanStyle( + color = Color.Blue, + textDecoration = TextDecoration.Underline + ), + start, + length + ) + addStringAnnotation( + tag = URL_TAG, + annotation = child.destination, + start = start, + end = length + ) } - // Bold: **text** or __text__ - text.startsWith("**", currentIndex) || text.startsWith("__", currentIndex) -> { - currentIndex = processBold(text, currentIndex) + is Emphasis -> { + val start = length + processNode(child) + addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, length) } - // Italic: *text* or _text_ - text[currentIndex] == '*' || text[currentIndex] == '_' -> { - currentIndex = processItalic(text, currentIndex) + is StrongEmphasis -> { + val start = length + processNode(child) + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, length) } - // Inline code: `text` - text[currentIndex] == '`' -> { - currentIndex = processInlineCode(text, currentIndex) + 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") + } } - else -> { - append(text[currentIndex]) - currentIndex++ + 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(10)) + // 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) } - } -} - -private fun AnnotatedString.Builder.processBoldItalic(text: String, startIndex: Int): Int { - val delimiter = text.substring(startIndex, startIndex + TRIPLE_DELIMITER_LENGTH) - val endIndex = text.indexOf(delimiter, startIndex + TRIPLE_DELIMITER_LENGTH) - return if (endIndex != -1 && endIndex > startIndex + TRIPLE_DELIMITER_LENGTH) { - val start = length - append(text.substring(startIndex + TRIPLE_DELIMITER_LENGTH, endIndex)) - addStyle( - SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), - start, - length - ) - endIndex + TRIPLE_DELIMITER_LENGTH - } else { - append(text[startIndex]) - startIndex + SINGLE_DELIMITER_LENGTH - } -} - -private fun AnnotatedString.Builder.processBold(text: String, startIndex: Int): Int { - val delimiter = text.substring(startIndex, startIndex + DOUBLE_DELIMITER_LENGTH) - val endIndex = text.indexOf(delimiter, startIndex + DOUBLE_DELIMITER_LENGTH) - return if (endIndex != -1 && endIndex > startIndex + DOUBLE_DELIMITER_LENGTH) { - val start = length - append(text.substring(startIndex + DOUBLE_DELIMITER_LENGTH, endIndex)) - addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, length) - endIndex + DOUBLE_DELIMITER_LENGTH - } else { - append(text[startIndex]) - startIndex + SINGLE_DELIMITER_LENGTH - } -} - -private fun AnnotatedString.Builder.processItalic(text: String, startIndex: Int): Int { - val delimiter = text[startIndex] - val endIndex = text.indexOf(delimiter, startIndex + SINGLE_DELIMITER_LENGTH) - return if (endIndex != -1 && endIndex != startIndex + SINGLE_DELIMITER_LENGTH) { - val start = length - append(text.substring(startIndex + SINGLE_DELIMITER_LENGTH, endIndex)) - addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, length) - endIndex + SINGLE_DELIMITER_LENGTH - } else { - append(text[startIndex]) - startIndex + SINGLE_DELIMITER_LENGTH - } -} - -private fun AnnotatedString.Builder.processInlineCode(text: String, startIndex: Int): Int { - val endIndex = text.indexOf('`', startIndex + SINGLE_DELIMITER_LENGTH) - return if (endIndex != -1) { - val start = length - append(text.substring(startIndex + SINGLE_DELIMITER_LENGTH, endIndex)) - addStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = Color.Gray.copy(alpha = CODE_BACKGROUND_ALPHA) - ), - start, - length - ) - endIndex + SINGLE_DELIMITER_LENGTH - } else { - append(text[startIndex]) - startIndex + SINGLE_DELIMITER_LENGTH + child = child.next } } 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 index 25a32ee2af72..ac37075d3185 100644 --- 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 @@ -70,11 +70,12 @@ class MarkdownUtilsTest { val result = markdownToAnnotatedString(input) assertThat(result.text).isEqualTo("This is bold and italic text") - assertThat(result.spanStyles).hasSize(1) - assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) - assertThat(result.spanStyles[0].item.fontStyle).isEqualTo(FontStyle.Italic) - assertThat(result.spanStyles[0].start).isEqualTo(8) - assertThat(result.spanStyles[0].end).isEqualTo(23) + // 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 @@ -83,11 +84,12 @@ class MarkdownUtilsTest { val result = markdownToAnnotatedString(input) assertThat(result.text).isEqualTo("This is bold and italic text") - assertThat(result.spanStyles).hasSize(1) - assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) - assertThat(result.spanStyles[0].item.fontStyle).isEqualTo(FontStyle.Italic) - assertThat(result.spanStyles[0].start).isEqualTo(8) - assertThat(result.spanStyles[0].end).isEqualTo(23) + // 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 @@ -146,14 +148,17 @@ class MarkdownUtilsTest { } @Test - fun `nested markdown formats are not supported and treated literally`() { + fun `nested markdown formats are properly supported`() { val input = "**bold *and italic* combined**" val result = markdownToAnnotatedString(input) - // The outer bold will be applied to "bold *and italic* combined" - assertThat(result.text).isEqualTo("bold *and italic* combined") - assertThat(result.spanStyles).hasSize(1) - assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) + // 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 @@ -311,10 +316,12 @@ class MarkdownUtilsTest { val input = "Line 1 **bold**\nLine 2 *italic*\nLine 3 normal" val result = markdownToAnnotatedString(input) - assertThat(result.text).isEqualTo("Line 1 bold\nLine 2 italic\nLine 3 normal") - assertThat(result.spanStyles).hasSize(2) - assertThat(result.spanStyles[0].item.fontWeight).isEqualTo(FontWeight.Bold) - assertThat(result.spanStyles[1].item.fontStyle).isEqualTo(FontStyle.Italic) + // 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 @@ -347,4 +354,366 @@ class MarkdownUtilsTest { 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 string 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.getStringAnnotations("URL", 0, result.text.length) + assertThat(annotations).hasSize(1) + assertThat(annotations[0].item).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.getStringAnnotations("URL", 0, result.text.length) + assertThat(annotations).hasSize(2) + assertThat(annotations[0].item).isEqualTo("http://one.com") + assertThat(annotations[1].item).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.getStringAnnotations("URL", 0, result.text.length) + assertThat(annotations).hasSize(1) + assertThat(annotations[0].item).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.getStringAnnotations("URL", 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.getStringAnnotations("URL", 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.getStringAnnotations("URL", 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.getStringAnnotations("URL", 0, result.text.length) + assertThat(annotations).hasSize(1) + assertThat(annotations[0].item).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.getStringAnnotations("URL", 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.getStringAnnotations("URL", 0, result.text.length) + assertThat(annotations).hasSize(1) + assertThat(annotations[0].item).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 == '─' } / 20 + 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.getStringAnnotations("URL", 0, result.text.length) + assertThat(annotations).hasSize(1) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e68832efc304..6751b47fcef4 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" } From ce4641fcb5498aab6b1be7b1ce0bd6569ee4e6e1 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 18:22:53 +0100 Subject: [PATCH 80/81] New links support --- .../aibot/ui/AIBotConversationDetailScreen.kt | 31 +++---- .../he/ui/HEConversationDetailScreen.kt | 7 +- .../android/ui/compose/utils/MarkdownUtils.kt | 43 +++++----- .../ui/compose/utils/MarkdownUtilsTest.kt | 81 ++++++++++--------- 4 files changed, 84 insertions(+), 78 deletions(-) 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 50c731d272ac..5b3d1b494ba9 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 @@ -37,25 +41,21 @@ 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.stringResource 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 kotlinx.coroutines.launch 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 @OptIn(ExperimentalMaterial3Api::class) @@ -275,12 +275,13 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re Column { Text( text = message.formattedText, - style = MaterialTheme.typography.bodyMedium, - color = if (message.isWrittenByUser) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + style = MaterialTheme.typography.bodyMedium.copy( + color = if (message.isWrittenByUser) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) ) Spacer(modifier = Modifier.height(4.dp)) 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 28f4b3a42dbb..783ee57ba009 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,7 +40,6 @@ 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 @@ -49,6 +48,7 @@ import androidx.compose.ui.res.stringResource 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 @@ -288,8 +288,9 @@ private fun MessageItem( Text( text = message.formattedText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ) ) } } 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 index 08179552ce8e..5b609648332d 100644 --- 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 @@ -2,12 +2,14 @@ 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 @@ -23,7 +25,6 @@ import org.commonmark.node.ThematicBreak import org.commonmark.parser.Parser private const val CODE_BACKGROUND_ALPHA = 0.2f -private const val URL_TAG = "URL" /** * Convert markdown text to Compose AnnotatedString using the CommonMark library. @@ -45,17 +46,17 @@ private const val URL_TAG = "URL" * This provides visual emphasis while maintaining a consistent text flow for chat-like UIs. * * ## List Handling - * Unordered list items are prefixed with "• " (bullet point). List formatting is preserved + * 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 URL annotations that can be - * used with ClickableText to handle clicks. The URL is stored as a string annotation - * with the tag "URL". + * 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 are annotated but not - * automatically opened - the calling code must handle URL clicks and validate URLs. + * 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 @@ -88,22 +89,18 @@ private fun AnnotatedString.Builder.processNode(node: Node) { ) } is Link -> { - val start = length - processNode(child) - addStyle( - SpanStyle( - color = Color.Blue, - textDecoration = TextDecoration.Underline - ), - start, - length - ) - addStringAnnotation( - tag = URL_TAG, - annotation = child.destination, - start = start, - end = length - ) + 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 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 index ac37075d3185..5db8d5d91c3b 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -372,15 +373,16 @@ class MarkdownUtilsTest { } @Test - fun `link URL is stored as string annotation`() { + 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.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) - assertThat(annotations[0].item).isEqualTo("https://example.com") + 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) } @@ -392,10 +394,12 @@ class MarkdownUtilsTest { assertThat(result.text).isEqualTo("See link1 and link2") - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(2) - assertThat(annotations[0].item).isEqualTo("http://one.com") - assertThat(annotations[1].item).isEqualTo("http://two.com") + 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 @@ -410,9 +414,10 @@ class MarkdownUtilsTest { val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } assertThat(hasBold).isTrue() - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) - assertThat(annotations[0].item).isEqualTo("https://example.com") + val linkAnnotation = annotations[0].item as LinkAnnotation.Url + assertThat(linkAnnotation.url).isEqualTo("https://example.com") } @Test @@ -422,7 +427,7 @@ class MarkdownUtilsTest { assertThat(result.text).isEqualTo("Start link here") - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) assertThat(annotations[0].start).isEqualTo(0) } @@ -434,7 +439,7 @@ class MarkdownUtilsTest { assertThat(result.text).isEqualTo("End with this link") - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) assertThat(annotations[0].end).isEqualTo(result.text.length) } @@ -446,7 +451,7 @@ class MarkdownUtilsTest { assertThat(result.text).isEqualTo("Everything is a link") - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + 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) @@ -459,9 +464,10 @@ class MarkdownUtilsTest { assertThat(result.text).isEqualTo("Go to search") - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) - assertThat(annotations[0].item).isEqualTo("https://example.com/search?q=test&lang=en") + val linkAnnotation = annotations[0].item as LinkAnnotation.Url + assertThat(linkAnnotation.url).isEqualTo("https://example.com/search?q=test&lang=en") } // Heading Tests @@ -542,7 +548,7 @@ class MarkdownUtilsTest { val hasBold = result.spanStyles.any { it.item.fontWeight == FontWeight.Bold } assertThat(hasBold).isTrue() // Should have link annotation - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) } @@ -567,9 +573,9 @@ class MarkdownUtilsTest { 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") + assertThat(result.text).contains("- First item") + assertThat(result.text).contains("- Second item") + assertThat(result.text).contains("- Third item") } @Test @@ -577,8 +583,8 @@ class MarkdownUtilsTest { val input = "* Item one\n* Item two" val result = markdownToAnnotatedString(input) - assertThat(result.text).contains("• Item one") - assertThat(result.text).contains("• Item two") + assertThat(result.text).contains("- Item one") + assertThat(result.text).contains("- Item two") } @Test @@ -586,9 +592,9 @@ class MarkdownUtilsTest { 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") + 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 } @@ -604,12 +610,13 @@ class MarkdownUtilsTest { 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") + assertThat(result.text).contains("- Check this link") + assertThat(result.text).contains("- Another item") - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) - assertThat(annotations[0].item).isEqualTo("https://example.com") + val linkAnnotation = annotations[0].item as LinkAnnotation.Url + assertThat(linkAnnotation.url).isEqualTo("https://example.com") } @Test @@ -617,7 +624,7 @@ class MarkdownUtilsTest { val input = "- List item\n\nRegular paragraph" val result = markdownToAnnotatedString(input) - assertThat(result.text).contains("• List item") + assertThat(result.text).contains("- List item") assertThat(result.text).contains("Regular paragraph") } @@ -626,7 +633,7 @@ class MarkdownUtilsTest { val input = "- Only one item" val result = markdownToAnnotatedString(input) - assertThat(result.text).isEqualTo("• Only one item") + assertThat(result.text).isEqualTo("- Only one item") } // Horizontal Rule Tests @@ -637,7 +644,7 @@ class MarkdownUtilsTest { val result = markdownToAnnotatedString(input) assertThat(result.text).contains("Before") - assertThat(result.text).contains("────────────────────") + assertThat(result.text).contains("──────────") assertThat(result.text).contains("After") } @@ -647,7 +654,7 @@ class MarkdownUtilsTest { val result = markdownToAnnotatedString(input) assertThat(result.text).contains("Text above") - assertThat(result.text).contains("────────────────────") + assertThat(result.text).contains("──────────") assertThat(result.text).contains("Text below") } @@ -657,7 +664,7 @@ class MarkdownUtilsTest { val result = markdownToAnnotatedString(input) assertThat(result.text).contains("Start") - assertThat(result.text).contains("────────────────────") + assertThat(result.text).contains("──────────") assertThat(result.text).contains("End") } @@ -666,7 +673,7 @@ class MarkdownUtilsTest { val input = "Section 1\n\n---\n\nSection 2\n\n---\n\nSection 3" val result = markdownToAnnotatedString(input) - val hrCount = result.text.count { it == '─' } / 20 + val hrCount = result.text.count { it == '─' } / 10 assertThat(hrCount).isEqualTo(2) } @@ -697,10 +704,10 @@ class MarkdownUtilsTest { 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("- 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 @@ -713,7 +720,7 @@ class MarkdownUtilsTest { assertThat(hasCode).isTrue() // Check link annotation - val annotations = result.getStringAnnotations("URL", 0, result.text.length) + val annotations = result.getLinkAnnotations(0, result.text.length) assertThat(annotations).hasSize(1) } } From 55d37a543d9e03734f9cbd3469140b7a1ef9835b Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 18:23:01 +0100 Subject: [PATCH 81/81] Detekt --- .../org/wordpress/android/ui/compose/utils/MarkdownUtils.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 5b609648332d..3b9ef5862dfb 100644 --- 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 @@ -71,6 +71,9 @@ fun markdownToAnnotatedString(markdownText: String): AnnotatedString { } } +private const val SECTION_DIVIDER_SIZE = 10 + +@Suppress("LongMethod", "CyclomaticComplexMethod") private fun AnnotatedString.Builder.processNode(node: Node) { var child = node.firstChild while (child != null) { @@ -137,7 +140,7 @@ private fun AnnotatedString.Builder.processNode(node: Node) { } } is ThematicBreak -> { - append("─".repeat(10)) + append("─".repeat(SECTION_DIVIDER_SIZE)) // Add newline after horizontal rule if it's not the last one if (child.next != null) { append("\n\n")