From 80d4ed3e5382ffae66c8145b63be7c4d2b9cd765 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 17 Oct 2025 12:23:15 +0200 Subject: [PATCH 001/153] 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 002/153] 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 003/153] 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 004/153] 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 005/153] 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 006/153] 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 007/153] 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 008/153] 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 009/153] 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 010/153] 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 011/153] 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 012/153] 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 013/153] 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 014/153] 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 015/153] 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 016/153] 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 017/153] 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 018/153] 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 019/153] 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 020/153] 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 021/153] 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 022/153] 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 023/153] 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 024/153] 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 025/153] 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 026/153] 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 027/153] 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 028/153] 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 029/153] 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 030/153] 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 031/153] 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 032/153] 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 033/153] 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 034/153] 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 035/153] 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 036/153] 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 037/153] 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 038/153] 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 039/153] 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 040/153] 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 041/153] 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 042/153] 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 043/153] 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 044/153] 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 045/153] 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 046/153] 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 047/153] 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 048/153] 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 049/153] 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 050/153] 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 051/153] 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 052/153] 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 053/153] 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 054/153] 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 055/153] 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 056/153] 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 057/153] 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 058/153] 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 059/153] 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 060/153] 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 061/153] 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 062/153] 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 063/153] 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 064/153] 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 065/153] 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 066/153] 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 067/153] 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 068/153] 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 069/153] 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 070/153] 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 071/153] 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 072/153] 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 073/153] 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 074/153] 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 075/153] 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 076/153] 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 077/153] 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 078/153] 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 a07d01cf4ebe1b1b9fedb61bee1a4f39367f5dd0 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:28:34 +0100 Subject: [PATCH 079/153] Support pagination --- .../repository/AIBotSupportRepository.kt | 12 ++- .../aibot/ui/AIBotConversationDetailScreen.kt | 89 +++++++++++++++---- .../support/aibot/ui/AIBotSupportActivity.kt | 7 ++ .../support/aibot/ui/AIBotSupportViewModel.kt | 61 ++++++++++++- 4 files changed, 145 insertions(+), 24 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 98578659236b..e555fcb027b9 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 @@ -15,6 +15,7 @@ import uniffi.wp_api.AddMessageToBotConversationParams import uniffi.wp_api.BotConversationSummary import uniffi.wp_api.CreateBotConversationParams import uniffi.wp_api.GetBotConversationParams +import java.util.Date import javax.inject.Inject import javax.inject.Named @@ -67,12 +68,15 @@ class AIBotSupportRepository @Inject constructor( } } - suspend fun loadConversation(chatId: Long): BotConversation? = withContext(ioDispatcher) { + suspend fun loadConversation(chatId: Long, pageNumber: Long = 1L): BotConversation? = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportBots().getBotConversation( botId = BOT_ID, chatId = chatId.toULong(), - params = GetBotConversationParams() + params = GetBotConversationParams( + pageNumber = pageNumber.toULong(), + itemsPerPage = 4U + ) ) } when (response) { @@ -158,8 +162,8 @@ class AIBotSupportRepository @Inject constructor( BotConversation ( id = chatId.toLong(), createdAt = createdAt, - mostRecentMessageDate = messages.last().createdAt, - lastMessage = messages.last().content, + mostRecentMessageDate = messages.lastOrNull()?.createdAt ?: Date(), + lastMessage = messages.lastOrNull()?.content.orEmpty(), messages = messages.map { it.toBotMessage() } ) 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..148537e73062 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 @@ -35,9 +35,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import kotlinx.coroutines.launch +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -65,26 +64,49 @@ fun AIBotConversationDetailScreen( conversation: BotConversation, isLoading: Boolean, isBotTyping: Boolean, + isLoadingOlderMessages: Boolean, + hasMorePages: Boolean, canSendMessage: Boolean, userName: String, onBackClick: () -> Unit, - onSendMessage: (String) -> Unit + onSendMessage: (String) -> Unit, + onLoadOlderMessages: () -> Unit ) { var messageText by remember { mutableStateOf("") } val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() - - // Scroll to bottom when conversation changes or messages are added or typing state changes - LaunchedEffect(conversation.id, conversation.messages.size, isBotTyping) { - if (conversation.messages.isNotEmpty() || isBotTyping) { - coroutineScope.launch { - // +2 for welcome header and spacer, +1 if typing indicator is showing - val itemCount = conversation.messages.size + 2 + if (isBotTyping) 1 else 0 - listState.animateScrollToItem(itemCount) - } + + // Scroll to bottom when new messages are added at the end (not when loading older messages at the beginning) + // Only scroll to bottom when: + // 1. The last message changes (new message added at the end) + // 2. Bot starts typing + // 3. We're not loading older messages (which adds messages at the beginning) + LaunchedEffect(conversation.id, conversation.messages.lastOrNull()?.id, isBotTyping) { + if ((conversation.messages.isNotEmpty() || isBotTyping) && !isLoadingOlderMessages) { + listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) } } + // Detect when user scrolls to the top to load older messages + LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) { + snapshotFlow { listState.firstVisibleItemIndex } + .collect { firstVisibleIndex -> + // Trigger pagination when user scrolls to the top + // The top threshold depends on whether we're currently showing a loading indicator + val shouldLoadMore = if (isLoadingOlderMessages) { + // If loading indicator is shown at position 0, we shouldn't trigger again + false + } else { + // Check if we're at the very top (index 0) + // Note: when hasMorePages=true, there's no welcome header, so index 0 is the first message + firstVisibleIndex == 0 + } + + if (shouldLoadMore && !isLoading && hasMorePages) { + onLoadOlderMessages() + } + } + } + val resources = LocalResources.current Scaffold( @@ -128,8 +150,25 @@ fun AIBotConversationDetailScreen( state = listState, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - WelcomeHeader(userName) + // Show loading indicator at top when loading older messages + if (isLoadingOlderMessages) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + + // Only show welcome header when we're at the beginning (no more pages to load) + if (!hasMorePages) { + item { + WelcomeHeader(userName) + } } // Key ensures the items recompose when messages change @@ -368,9 +407,12 @@ private fun ConversationDetailScreenPreview() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -388,9 +430,12 @@ private fun ConversationDetailScreenPreviewDark() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -408,9 +453,12 @@ private fun ConversationDetailScreenWordPressPreview() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -428,9 +476,12 @@ private fun ConversationDetailScreenPreviewWordPressDark() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt index 79f2bde12052..6d6123dc53ba 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 @@ -131,6 +131,8 @@ class AIBotSupportActivity : AppCompatActivity() { val selectedConversation by viewModel.selectedConversation.collectAsState() val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isBotTyping by viewModel.isBotTyping.collectAsState() + val isLoadingOlderMessages by viewModel.isLoadingOlderMessages.collectAsState() + val hasMorePages by viewModel.hasMorePages.collectAsState() val canSendMessage by viewModel.canSendMessage.collectAsState() val userInfo by viewModel.userInfo.collectAsState() selectedConversation?.let { conversation -> @@ -140,10 +142,15 @@ class AIBotSupportActivity : AppCompatActivity() { conversation = conversation, isLoading = isLoadingConversation, isBotTyping = isBotTyping, + isLoadingOlderMessages = isLoadingOlderMessages, + hasMorePages = hasMorePages, canSendMessage = canSendMessage, onBackClick = { viewModel.onBackClick() }, onSendMessage = { text -> viewModel.sendMessage(text) + }, + onLoadOlderMessages = { + viewModel.loadOlderMessages() } ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt index 7f750e574930..3631fb318dfd 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,6 +29,14 @@ class AIBotSupportViewModel @Inject constructor( private val _isBotTyping = MutableStateFlow(false) val isBotTyping: StateFlow = _isBotTyping.asStateFlow() + private val _isLoadingOlderMessages = MutableStateFlow(false) + val isLoadingOlderMessages: StateFlow = _isLoadingOlderMessages.asStateFlow() + + private val _hasMorePages = MutableStateFlow(true) + val hasMorePages: StateFlow = _hasMorePages.asStateFlow() + + private var currentPage = 1L + override fun initRepository(accessToken: String) { aiBotSupportRepository.init(accessToken, accountStore.account.userId) } @@ -37,8 +45,12 @@ class AIBotSupportViewModel @Inject constructor( override suspend fun getConversation(conversationId: Long): BotConversation? { _canSendMessage.value = false - return aiBotSupportRepository.loadConversation(conversationId).also { + currentPage = 1L + _hasMorePages.value = true + return aiBotSupportRepository.loadConversation(conversationId, pageNumber = currentPage).also { conversation -> _canSendMessage.value = true + // Check if there are more pages (empty messages means end of pagination) + _hasMorePages.value = conversation?.messages?.isNotEmpty() == true } } @@ -53,10 +65,57 @@ class AIBotSupportViewModel @Inject constructor( messages = listOf() ) _canSendMessage.value = true + currentPage = 1L + _hasMorePages.value = false setNewConversation(botConversation) } } + fun loadOlderMessages() { + if (!_hasMorePages.value || _isLoadingOlderMessages.value) { + return + } + + viewModelScope.launch { + try { + _isLoadingOlderMessages.value = true + val conversationId = _selectedConversation.value?.id ?: return@launch + + currentPage++ + val olderMessagesConversation = aiBotSupportRepository.loadConversation( + conversationId, + pageNumber = currentPage + ) + + if (olderMessagesConversation != null) { + val olderMessages = olderMessagesConversation.messages + + // Check if we've reached the end (empty messages) + if (olderMessages.isEmpty()) { + _hasMorePages.value = false + } else { + // Prepend older messages to the existing ones + // (older messages go at the beginning of the list) + val currentMessages = _selectedConversation.value?.messages ?: emptyList() + _selectedConversation.value = _selectedConversation.value?.copy( + messages = olderMessages + currentMessages + ) + } + } else { + // Error loading, stay on current page + currentPage-- + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") + } + } catch (throwable: Throwable) { + currentPage-- + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } finally { + _isLoadingOlderMessages.value = false + } + } + } + @Suppress("TooGenericExceptionCaught") fun sendMessage(message: String) { viewModelScope.launch { From b8fe4af2f3940f81637edbe0dd4faf9c952b14e6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:44:28 +0100 Subject: [PATCH 080/153] Triggering in the 4th element --- .../aibot/ui/AIBotConversationDetailScreen.kt | 16 +++++----------- 1 file changed, 5 insertions(+), 11 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 148537e73062..56a4f343023b 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 @@ -86,20 +86,14 @@ fun AIBotConversationDetailScreen( } } - // Detect when user scrolls to the top to load older messages + // Detect when user scrolls near the top to load older messages LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) { snapshotFlow { listState.firstVisibleItemIndex } .collect { firstVisibleIndex -> - // Trigger pagination when user scrolls to the top - // The top threshold depends on whether we're currently showing a loading indicator - val shouldLoadMore = if (isLoadingOlderMessages) { - // If loading indicator is shown at position 0, we shouldn't trigger again - false - } else { - // Check if we're at the very top (index 0) - // Note: when hasMorePages=true, there's no welcome header, so index 0 is the first message - firstVisibleIndex == 0 - } + // Trigger pagination when user reaches the 4th message from the top + val threshold = 4 + + val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= threshold if (shouldLoadMore && !isLoading && hasMorePages) { onLoadOlderMessages() From 0ef0205002a9c11fb7cddc7f7fe4fe0c5ab3db2e Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:47:25 +0100 Subject: [PATCH 081/153] detekt --- .../android/support/aibot/ui/AIBotSupportViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) 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 3631fb318dfd..3b1ac7d438fd 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 @@ -71,6 +71,7 @@ class AIBotSupportViewModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") fun loadOlderMessages() { if (!_hasMorePages.value || _isLoadingOlderMessages.value) { return @@ -104,10 +105,12 @@ class AIBotSupportViewModel @Inject constructor( } else { // Error loading, stay on current page currentPage-- + _errorMessage.value = ErrorType.GENERAL appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") } } catch (throwable: Throwable) { currentPage-- + _errorMessage.value = ErrorType.GENERAL appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + "${throwable.message} - ${throwable.stackTraceToString()}") } finally { From 836f3dd37a9ddc512624453d2d530cbab6d35b81 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:49:43 +0100 Subject: [PATCH 082/153] TODO for debug purposes --- .../android/support/aibot/repository/AIBotSupportRepository.kt | 2 ++ 1 file changed, 2 insertions(+) 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 e555fcb027b9..6bd408722719 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 @@ -75,6 +75,8 @@ class AIBotSupportRepository @Inject constructor( chatId = chatId.toULong(), params = GetBotConversationParams( pageNumber = pageNumber.toULong(), + // TODO: this is set to 4 for testing purpose + // The TODO is preventing the Pr to be merged. Change it to a higher number before that itemsPerPage = 4U ) ) From e126063c888f0b38ec169d7b2408b654ba07c486 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 14:06:25 +0100 Subject: [PATCH 083/153] Claude PR suggestions Mutex and constant --- .../aibot/ui/AIBotConversationDetailScreen.kt | 7 +- .../support/aibot/ui/AIBotSupportViewModel.kt | 71 +++++++++++-------- 2 files changed, 43 insertions(+), 35 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 56a4f343023b..ab8386bdcf7b 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,8 @@ import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage import org.wordpress.android.ui.compose.theme.AppThemeM3 +private const val PAGINATION_TRIGGER_THRESHOLD = 4 + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AIBotConversationDetailScreen( @@ -90,10 +92,7 @@ fun AIBotConversationDetailScreen( LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) { snapshotFlow { listState.firstVisibleItemIndex } .collect { firstVisibleIndex -> - // Trigger pagination when user reaches the 4th message from the top - val threshold = 4 - - val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= threshold + val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= PAGINATION_TRIGGER_THRESHOLD if (shouldLoadMore && !isLoading && hasMorePages) { onLoadOlderMessages() 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 3b1ac7d438fd..3faa487c904a 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.aibot.model.BotConversation @@ -35,6 +37,7 @@ class AIBotSupportViewModel @Inject constructor( private val _hasMorePages = MutableStateFlow(true) val hasMorePages: StateFlow = _hasMorePages.asStateFlow() + private val paginationMutex = Mutex() private var currentPage = 1L override fun initRepository(accessToken: String) { @@ -49,8 +52,6 @@ class AIBotSupportViewModel @Inject constructor( _hasMorePages.value = true return aiBotSupportRepository.loadConversation(conversationId, pageNumber = currentPage).also { conversation -> _canSendMessage.value = true - // Check if there are more pages (empty messages means end of pagination) - _hasMorePages.value = conversation?.messages?.isNotEmpty() == true } } @@ -78,43 +79,51 @@ class AIBotSupportViewModel @Inject constructor( } viewModelScope.launch { - try { - _isLoadingOlderMessages.value = true - val conversationId = _selectedConversation.value?.id ?: return@launch + // Use mutex to prevent concurrent pagination requests + paginationMutex.withLock { + // Double-check conditions after acquiring lock + if (!_hasMorePages.value || _isLoadingOlderMessages.value) { + return@launch + } - currentPage++ - val olderMessagesConversation = aiBotSupportRepository.loadConversation( - conversationId, - pageNumber = currentPage - ) + try { + _isLoadingOlderMessages.value = true + val conversationId = _selectedConversation.value?.id ?: return@withLock - if (olderMessagesConversation != null) { - val olderMessages = olderMessagesConversation.messages + currentPage++ + val olderMessagesConversation = aiBotSupportRepository.loadConversation( + conversationId, + pageNumber = currentPage + ) - // Check if we've reached the end (empty messages) - if (olderMessages.isEmpty()) { - _hasMorePages.value = false + if (olderMessagesConversation != null) { + val olderMessages = olderMessagesConversation.messages + + // Check if we've reached the end (empty messages) + if (olderMessages.isEmpty()) { + _hasMorePages.value = false + } else { + // Prepend older messages to the existing ones + // (older messages go at the beginning of the list) + val currentMessages = _selectedConversation.value?.messages ?: emptyList() + _selectedConversation.value = _selectedConversation.value?.copy( + messages = olderMessages + currentMessages + ) + } } else { - // Prepend older messages to the existing ones - // (older messages go at the beginning of the list) - val currentMessages = _selectedConversation.value?.messages ?: emptyList() - _selectedConversation.value = _selectedConversation.value?.copy( - messages = olderMessages + currentMessages - ) + // Error loading, stay on current page + currentPage-- + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") } - } else { - // Error loading, stay on current page + } catch (throwable: Throwable) { currentPage-- _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } finally { + _isLoadingOlderMessages.value = false } - } catch (throwable: Throwable) { - currentPage-- - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + - "${throwable.message} - ${throwable.stackTraceToString()}") - } finally { - _isLoadingOlderMessages.value = false } } } From ce8deba2c64d97ff0b0e522db5a7caacc54f9caa Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 08:44:19 +0100 Subject: [PATCH 084/153] Put ConversationListView in common between bots and HE --- .../aibot/ui/AIBotConversationsListScreen.kt | 113 +++------------- .../support/aibot/ui/AIBotSupportActivity.kt | 3 +- .../common/ui/ConversationsListScreen.kt | 101 +++++++++++++++ .../he/ui/HEConversationsListScreen.kt | 121 ++++-------------- .../support/he/ui/HESupportActivity.kt | 6 +- 5 files changed, 154 insertions(+), 190 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt 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 57752a0fb9ee..1ad4749e5797 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 @@ -25,8 +25,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,109 +35,46 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import org.wordpress.android.R import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.aibot.util.generateSampleBotConversations -import org.wordpress.android.support.common.ui.EmptyConversationsView +import org.wordpress.android.support.common.ui.ConversationsListScreen import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun AIBotConversationsListScreen( snackbarHostState: SnackbarHostState, - conversations: StateFlow>, + conversations: List, isLoading: Boolean, onConversationClick: (BotConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, onRefresh: () -> Unit, ) { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.ai_bot_conversations_title)) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(R.string.ai_bot_back_button_content_description) - ) - } - }, - actions = { - IconButton(onClick = { onCreateNewConversationClick() }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.ai_bot_new_conversation_content_description) - ) - } - } - ) - }, - ) { contentPadding -> - val conversationsList by conversations.collectAsState() - - PullToRefreshBox( - isRefreshing = isLoading, - onRefresh = onRefresh, - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - ) { - when { - conversationsList.isEmpty() && !isLoading -> { - EmptyConversationsView( - modifier = Modifier.fillMaxSize(), - onCreateNewConversationClick = onCreateNewConversationClick - ) - } - else -> { - ShowConversationsList( - modifier = Modifier.fillMaxSize(), - conversations = conversations, - onConversationClick = onConversationClick - ) - } - } - } - } -} - -@Composable -private fun ShowConversationsList( - modifier: Modifier, - conversations: StateFlow>, - onConversationClick: (BotConversation) -> Unit -) { - val conversations by conversations.collectAsState() val resources = LocalResources.current - - LazyColumn( - modifier = modifier.fillMaxSize() - ) { - items( - items = conversations, - key = { it.id } - ) { conversation -> - ConversationListItem( + ConversationsListScreen( + title = stringResource(R.string.ai_bot_conversations_title), + addConversationContentDescription = stringResource(R.string.ai_bot_new_conversation_content_description), + snackbarHostState = snackbarHostState, + conversations = conversations, + isLoading = isLoading, + onBackClick = onBackClick, + onCreateNewConversationClick = onCreateNewConversationClick, + onRefresh = onRefresh, + conversationListItem = { conversation -> + BotConversationListItem( conversation = conversation, resources = resources, onClick = { onConversationClick(conversation) } ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) } - } + ) } @Composable -private fun ConversationListItem( +private fun BotConversationListItem( conversation: BotConversation, resources: Resources, onClick: () -> Unit @@ -183,13 +118,12 @@ private fun ConversationListItem( @Preview(showBackground = true, name = "Conversations List") @Composable private fun ConversationsScreenPreview() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), + conversations = generateSampleBotConversations(), isLoading = false, onConversationClick = { }, onBackClick = { }, @@ -202,13 +136,12 @@ private fun ConversationsScreenPreview() { @Preview(showBackground = true, name = "Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewDark() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), + conversations = generateSampleBotConversations(), isLoading = false, onConversationClick = { }, onBackClick = { }, @@ -221,13 +154,12 @@ private fun ConversationsScreenPreviewDark() { @Preview(showBackground = true, name = "Conversations List") @Composable private fun ConversationsScreenWordPressPreview() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), + conversations = generateSampleBotConversations(), isLoading = true, onConversationClick = { }, onBackClick = { }, @@ -240,13 +172,12 @@ private fun ConversationsScreenWordPressPreview() { @Preview(showBackground = true, name = "Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewWordPressDark() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), + conversations = generateSampleBotConversations(), isLoading = true, onConversationClick = { }, onBackClick = { }, @@ -259,13 +190,12 @@ private fun ConversationsScreenPreviewWordPressDark() { @Preview(showBackground = true, name = "Empty Conversations List") @Composable private fun EmptyConversationsScreenPreview() { - val emptyConversations = MutableStateFlow(emptyList()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = emptyConversations.asStateFlow(), + conversations = emptyList(), isLoading = false, onConversationClick = { }, onBackClick = { }, @@ -278,13 +208,12 @@ private fun EmptyConversationsScreenPreview() { @Preview(showBackground = true, name = "Empty Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun EmptyConversationsScreenPreviewDark() { - val emptyConversations = MutableStateFlow(emptyList()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = emptyConversations.asStateFlow(), + conversations = emptyList(), isLoading = false, onConversationClick = { }, onBackClick = { }, 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 6d6123dc53ba..b643a71816e5 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,9 +110,10 @@ class AIBotSupportActivity : AppCompatActivity() { ) { composable(route = ConversationScreen.List.name) { val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + val conversations by viewModel.conversations.collectAsState() AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = viewModel.conversations, + conversations = conversations, isLoading = isLoadingConversations, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt new file mode 100644 index 000000000000..47cf2d6be690 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt @@ -0,0 +1,101 @@ +package org.wordpress.android.support.common.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.wordpress.android.support.common.model.Conversation +import org.wordpress.android.ui.compose.components.MainTopAppBar +import org.wordpress.android.ui.compose.components.NavigationIcons + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationsListScreen( + modifier: Modifier = Modifier, + title: String, + addConversationContentDescription: String, + snackbarHostState: SnackbarHostState, + conversations: List, + isLoading: Boolean, + onBackClick: () -> Unit, + onCreateNewConversationClick: () -> Unit, + onRefresh: () -> Unit, + conversationListItem: @Composable (T) -> Unit +) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + MainTopAppBar( + title = title, + navigationIcon = NavigationIcons.BackIcon, + onNavigationIconClick = onBackClick, + actions = { + IconButton(onClick = { onCreateNewConversationClick() }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = addConversationContentDescription + ) + } + } + ) + } + ) { contentPadding -> + PullToRefreshBox( + isRefreshing = isLoading, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize() + ) { + ConversationsList( + modifier = Modifier.padding(contentPadding), + conversations = conversations, + isLoading = isLoading, + onCreateNewConversationClick = onCreateNewConversationClick, + conversationListItem = conversationListItem, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationsList( + modifier: Modifier, + conversations: List, + isLoading: Boolean, + onCreateNewConversationClick: () -> Unit, + conversationListItem: @Composable (T) -> Unit +) { + if (conversations.isEmpty() && !isLoading) { + EmptyConversationsView( + modifier = modifier, + onCreateNewConversationClick = onCreateNewConversationClick + ) + } else { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { + items( + items = conversations, + key = { it.getConversationId() } + ) { conversation -> + conversationListItem(conversation) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt index f313c378d5da..0c93e1cca636 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 @@ -24,8 +24,6 @@ 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 @@ -36,11 +34,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime +import org.wordpress.android.support.common.ui.ConversationsListScreen import org.wordpress.android.support.common.ui.EmptyConversationsView import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.util.generateSampleHESupportConversations @@ -52,92 +48,35 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @Composable fun HEConversationsListScreen( snackbarHostState: SnackbarHostState, - conversations: StateFlow>, - isLoadingConversations: StateFlow, + conversations: List, + isLoading: Boolean, onConversationClick: (SupportConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, onRefresh: () -> Unit ) { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - MainTopAppBar( - title = stringResource(R.string.he_support_conversations_title), - navigationIcon = NavigationIcons.BackIcon, - onNavigationIconClick = onBackClick, - actions = { - IconButton(onClick = { onCreateNewConversationClick() }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource( - R.string.he_support_new_conversation_content_description - ) - ) - } - } - ) - } - ) { contentPadding -> - ShowConversationsList( - modifier = Modifier.padding(contentPadding), - conversations = conversations, - isLoadingConversations = isLoadingConversations, - onConversationClick = onConversationClick, - onRefresh = onRefresh, - onCreateNewConversationClick = onCreateNewConversationClick - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ShowConversationsList( - modifier: Modifier, - conversations: StateFlow>, - isLoadingConversations: StateFlow, - onConversationClick: (SupportConversation) -> Unit, - onRefresh: () -> Unit, - onCreateNewConversationClick: () -> Unit -) { - val conversationsList by conversations.collectAsState() - val isLoading by isLoadingConversations.collectAsState() val resources = LocalResources.current - - PullToRefreshBox( - isRefreshing = isLoading, + ConversationsListScreen( + title = stringResource(R.string.he_support_conversations_title), + addConversationContentDescription = stringResource(R.string.he_support_new_conversation_content_description), + snackbarHostState = snackbarHostState, + conversations = conversations, + isLoading = isLoading, + onBackClick = onBackClick, + onCreateNewConversationClick = onCreateNewConversationClick, onRefresh = onRefresh, - modifier = modifier.fillMaxSize() - ) { - if (conversationsList.isEmpty() && !isLoading) { - EmptyConversationsView( - modifier = Modifier, - onCreateNewConversationClick = onCreateNewConversationClick + conversationListItem = { conversation -> + HEConversationListItem( + conversation = conversation, + resources = resources, + onClick = { onConversationClick(conversation) } ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items( - items = conversationsList, - key = { it.id } - ) { conversation -> - ConversationListItem( - conversation = conversation, - resources = resources, - onClick = { onConversationClick(conversation) } - ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - } - } } - } + ) } @Composable -private fun ConversationListItem( +private fun HEConversationListItem( conversation: SupportConversation, resources: Resources, onClick: () -> Unit @@ -197,15 +136,13 @@ private fun ConversationListItem( @Preview(showBackground = true, name = "HE Support Conversations List") @Composable private fun ConversationsScreenPreview() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + isLoading = false, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -217,15 +154,13 @@ private fun ConversationsScreenPreview() { @Preview(showBackground = true, name = "HE Support Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewDark() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + isLoading = false, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -237,15 +172,13 @@ private fun ConversationsScreenPreviewDark() { @Preview(showBackground = true, name = "HE Support Conversations List - WordPress") @Composable private fun ConversationsScreenWordPressPreview() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + isLoading = false, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -257,15 +190,13 @@ private fun ConversationsScreenWordPressPreview() { @Preview(showBackground = true, name = "HE Support Conversations List - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewWordPressDark() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + isLoading = false, 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 0e27d22fdb3a..40f9c722e5ef 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -108,10 +108,12 @@ class HESupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name, ) { composable(route = ConversationScreen.List.name) { + val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + val conversations by viewModel.conversations.collectAsState() HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = viewModel.conversations, - isLoadingConversations = viewModel.isLoadingConversations, + conversations = conversations, + isLoading = isLoadingConversations, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, From fa2f147bde84b019cec9563385918af539fc9c53 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 09:08:09 +0100 Subject: [PATCH 085/153] Empty and error state --- .../aibot/ui/AIBotConversationsListScreen.kt | 17 +++--- .../support/aibot/ui/AIBotSupportActivity.kt | 4 +- .../support/aibot/ui/AIBotSupportViewModel.kt | 4 +- .../common/ui/ConversationsListScreen.kt | 14 +++-- .../ui/ConversationsSupportViewModel.kt | 23 +++++-- .../common/ui/ErrorConversationsView.kt | 61 +++++++++++++++++++ .../common/ui/OfflineConversationsView.kt | 61 +++++++++++++++++++ .../he/ui/HEConversationsListScreen.kt | 13 ++-- .../support/he/ui/HESupportActivity.kt | 4 +- .../support/he/ui/HESupportViewModel.kt | 4 +- 10 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/common/ui/ErrorConversationsView.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/common/ui/OfflineConversationsView.kt 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 1ad4749e5797..e9f73df1a63b 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 @@ -40,6 +40,7 @@ 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.ConversationsListScreen +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @@ -47,7 +48,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 fun AIBotConversationsListScreen( snackbarHostState: SnackbarHostState, conversations: List, - isLoading: Boolean, + conversationsState: ConversationsSupportViewModel.ConversationsState, onConversationClick: (BotConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, @@ -59,7 +60,7 @@ fun AIBotConversationsListScreen( addConversationContentDescription = stringResource(R.string.ai_bot_new_conversation_content_description), snackbarHostState = snackbarHostState, conversations = conversations, - isLoading = isLoading, + conversationsState = conversationsState, onBackClick = onBackClick, onCreateNewConversationClick = onCreateNewConversationClick, onRefresh = onRefresh, @@ -124,7 +125,7 @@ private fun ConversationsScreenPreview() { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleBotConversations(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -142,7 +143,7 @@ private fun ConversationsScreenPreviewDark() { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleBotConversations(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -160,7 +161,7 @@ private fun ConversationsScreenWordPressPreview() { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleBotConversations(), - isLoading = true, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -178,7 +179,7 @@ private fun ConversationsScreenPreviewWordPressDark() { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleBotConversations(), - isLoading = true, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -196,7 +197,7 @@ private fun EmptyConversationsScreenPreview() { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = emptyList(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -214,7 +215,7 @@ private fun EmptyConversationsScreenPreviewDark() { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = emptyList(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt index b643a71816e5..10adaf9963fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt @@ -109,12 +109,12 @@ class AIBotSupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name, ) { composable(route = ConversationScreen.List.name) { - val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + val conversationsState by viewModel.conversationsState.collectAsState() val conversations by viewModel.conversations.collectAsState() AIBotConversationsListScreen( snackbarHostState = snackbarHostState, conversations = conversations, - isLoading = isLoadingConversations, + conversationsState = conversationsState, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, 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 3faa487c904a..b069f10a44e8 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt @@ -16,6 +16,7 @@ import org.wordpress.android.support.aibot.repository.AIBotSupportRepository import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date import javax.inject.Inject @@ -24,7 +25,8 @@ class AIBotSupportViewModel @Inject constructor( accountStore: AccountStore, private val aiBotSupportRepository: AIBotSupportRepository, appLogWrapper: AppLogWrapper, -) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + networkUtilsWrapper: NetworkUtilsWrapper, +) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { private val _canSendMessage = MutableStateFlow(true) val canSendMessage: StateFlow = _canSendMessage.asStateFlow() diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt index 47cf2d6be690..801221632345 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt @@ -29,7 +29,7 @@ fun ConversationsListScreen( addConversationContentDescription: String, snackbarHostState: SnackbarHostState, conversations: List, - isLoading: Boolean, + conversationsState: ConversationsSupportViewModel.ConversationsState, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, onRefresh: () -> Unit, @@ -54,14 +54,14 @@ fun ConversationsListScreen( } ) { contentPadding -> PullToRefreshBox( - isRefreshing = isLoading, + isRefreshing = conversationsState is ConversationsSupportViewModel.ConversationsState.Loading, onRefresh = onRefresh, modifier = modifier.fillMaxSize() ) { ConversationsList( modifier = Modifier.padding(contentPadding), conversations = conversations, - isLoading = isLoading, + conversationsState = conversationsState, onCreateNewConversationClick = onCreateNewConversationClick, conversationListItem = conversationListItem, ) @@ -74,15 +74,19 @@ fun ConversationsListScreen( private fun ConversationsList( modifier: Modifier, conversations: List, - isLoading: Boolean, + conversationsState: ConversationsSupportViewModel.ConversationsState, onCreateNewConversationClick: () -> Unit, conversationListItem: @Composable (T) -> Unit ) { - if (conversations.isEmpty() && !isLoading) { + if (conversations.isEmpty() && conversationsState is ConversationsSupportViewModel.ConversationsState.Loaded) { EmptyConversationsView( modifier = modifier, onCreateNewConversationClick = onCreateNewConversationClick ) + } else if (conversationsState is ConversationsSupportViewModel.ConversationsState.NoNetwork) { + OfflineConversationsView() + } else if (conversationsState is ConversationsSupportViewModel.ConversationsState.Error) { + ErrorConversationsView() } else { LazyColumn( modifier = modifier.fillMaxSize() diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt index b9bf9ef6d692..7b99c87a740f 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt @@ -14,10 +14,12 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.model.Conversation import org.wordpress.android.support.common.model.UserInfo import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, + private val networkUtilsWrapper: NetworkUtilsWrapper, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToConversationDetail : NavigationEvent() @@ -25,6 +27,13 @@ abstract class ConversationsSupportViewModel( data object NavigateBack : NavigationEvent() } + sealed class ConversationsState { + data object Loading : ConversationsState() + data object Loaded : ConversationsState() + data object NoNetwork : ConversationsState() + data object Error : ConversationsState() + } + private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() @@ -44,8 +53,8 @@ abstract class ConversationsSupportViewModel( val userInfo: StateFlow = _userInfo.asStateFlow() @Suppress("VariableNaming") - protected val _isLoadingConversations = MutableStateFlow(false) - val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() + protected val _conversationsState = MutableStateFlow(ConversationsState.Loading) + val conversationsState: StateFlow = _conversationsState.asStateFlow() @Suppress("VariableNaming") protected val _errorMessage = MutableStateFlow(null) @@ -88,17 +97,23 @@ abstract class ConversationsSupportViewModel( @Suppress("TooGenericExceptionCaught") private suspend fun loadConversations() { try { - _isLoadingConversations.value = true + if (!networkUtilsWrapper.isNetworkAvailable()) { + _conversationsState.value = ConversationsState.NoNetwork + return + } + + _conversationsState.value = ConversationsState.Loading val conversations = getConversations() _conversations.value = conversations + _conversationsState.value = ConversationsState.Loaded } catch (throwable: Throwable) { _errorMessage.value = ErrorType.GENERAL + _conversationsState.value = ConversationsState.Error appLogWrapper.e( AppLog.T.SUPPORT, "Error loading support conversations: " + "${throwable.message} - ${throwable.stackTraceToString()}" ) } - _isLoadingConversations.value = false } protected abstract suspend fun getConversations(): List diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ErrorConversationsView.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ErrorConversationsView.kt new file mode 100644 index 000000000000..6d1232865d93 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ErrorConversationsView.kt @@ -0,0 +1,61 @@ +package org.wordpress.android.support.common.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.EmptyContentM3 +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@Composable +fun ErrorConversationsView() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmptyContentM3( + title = stringResource(R.string.error_generic), + image = R.drawable.img_jetpack_empty_state, + imageContentDescription = stringResource(R.string.error_generic) + ) + } +} + +@Preview(showBackground = true, name = "Error Conversations View") +@Composable +private fun ErrorConversationsViewPreview() { + AppThemeM3(isDarkTheme = false) { + ErrorConversationsView() + } +} + +@Preview(showBackground = true, name = "Error Conversations View - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ErrorConversationsViewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + ErrorConversationsView() + } +} + +@Preview(showBackground = true, name = "Error Conversations View - WordPress") +@Composable +private fun ErrorConversationsViewPreviewWordPress() { + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + ErrorConversationsView() + } +} + +@Preview(showBackground = true, name = "Error Conversations View - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ErrorConversationsViewPreviewWordPressDark() { + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + ErrorConversationsView() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/OfflineConversationsView.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/OfflineConversationsView.kt new file mode 100644 index 000000000000..aee979332f5d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/OfflineConversationsView.kt @@ -0,0 +1,61 @@ +package org.wordpress.android.support.common.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.EmptyContentM3 +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@Composable +fun OfflineConversationsView() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmptyContentM3( + title = stringResource(R.string.no_network_title), + image = R.drawable.img_illustration_cloud_off_152dp, + imageContentDescription = stringResource(R.string.no_network_title) + ) + } +} + +@Preview(showBackground = true, name = "Empty Conversations View") +@Composable +private fun OfflineConversationsViewPreview() { + AppThemeM3(isDarkTheme = false) { + OfflineConversationsView() + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun OfflineConversationsViewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + OfflineConversationsView() + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - WordPress") +@Composable +private fun OfflineConversationsViewPreviewWordPress() { + AppThemeM3(isDarkTheme = false, isJetpackApp = false) { + OfflineConversationsView() + } +} + +@Preview(showBackground = true, name = "Empty Conversations View - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun OfflineConversationsViewPreviewWordPressDark() { + AppThemeM3(isDarkTheme = true, isJetpackApp = false) { + OfflineConversationsView() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt index 0c93e1cca636..170fc9f0b151 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.common.ui.ConversationsListScreen +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.support.common.ui.EmptyConversationsView import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.util.generateSampleHESupportConversations @@ -49,7 +50,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 fun HEConversationsListScreen( snackbarHostState: SnackbarHostState, conversations: List, - isLoading: Boolean, + conversationsState: ConversationsSupportViewModel.ConversationsState, onConversationClick: (SupportConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, @@ -61,7 +62,7 @@ fun HEConversationsListScreen( addConversationContentDescription = stringResource(R.string.he_support_new_conversation_content_description), snackbarHostState = snackbarHostState, conversations = conversations, - isLoading = isLoading, + conversationsState = conversationsState, onBackClick = onBackClick, onCreateNewConversationClick = onCreateNewConversationClick, onRefresh = onRefresh, @@ -142,7 +143,7 @@ private fun ConversationsScreenPreview() { HEConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleHESupportConversations(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -160,7 +161,7 @@ private fun ConversationsScreenPreviewDark() { HEConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleHESupportConversations(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -178,7 +179,7 @@ private fun ConversationsScreenWordPressPreview() { HEConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleHESupportConversations(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -196,7 +197,7 @@ private fun ConversationsScreenPreviewWordPressDark() { HEConversationsListScreen( snackbarHostState = snackbarHostState, conversations = generateSampleHESupportConversations(), - isLoading = false, + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, 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 40f9c722e5ef..171abf010aa4 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -108,12 +108,12 @@ class HESupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name, ) { composable(route = ConversationScreen.List.name) { - val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + val conversationsState by viewModel.conversationsState.collectAsState() val conversations by viewModel.conversations.collectAsState() HEConversationsListScreen( snackbarHostState = snackbarHostState, conversations = conversations, - isLoading = isLoadingConversations, + conversationsState = conversationsState, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index 09371a142ffa..a0c54123a968 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -13,6 +13,7 @@ import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper import javax.inject.Inject @HiltViewModel @@ -20,7 +21,8 @@ class HESupportViewModel @Inject constructor( accountStore: AccountStore, private val heSupportRepository: HESupportRepository, appLogWrapper: AppLogWrapper, -) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + networkUtilsWrapper: NetworkUtilsWrapper, +) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { private val _isSendingMessage = MutableStateFlow(false) val isSendingMessage: StateFlow = _isSendingMessage.asStateFlow() From 971499aaedd14ad68d51d99d4a10b8ec18de0db9 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 09:23:58 +0100 Subject: [PATCH 086/153] Skip site capitalization --- .../wordpress/android/support/he/ui/HENewTicketScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 0de44b65e38a..aefe5d2aa064 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 @@ -43,6 +43,7 @@ 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.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.foundation.text.KeyboardOptions @@ -164,7 +165,10 @@ fun HENewTicketScreen( ) }, shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Uri + ), colors = OutlinedTextFieldDefaults.colors( unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) ) From 6ecf89da8c97e8cdd40f8bd6d27c804a9eed859f Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 09:31:10 +0100 Subject: [PATCH 087/153] Adding a11c labels --- .../support/he/ui/HENewTicketScreen.kt | 20 ++++++++++++---- .../support/he/ui/TicketMainContentView.kt | 23 ++++++++++++++----- 2 files changed, 32 insertions(+), 11 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 aefe5d2aa064..c28f1c163c2b 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 @@ -41,6 +41,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -123,8 +125,9 @@ fun HENewTicketScreen( SectionHeader(text = stringResource(R.string.he_support_issue_details)) + val subjectLabel = stringResource(R.string.he_support_subject_label) Text( - text = stringResource(R.string.he_support_subject_label), + text = subjectLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp) @@ -133,7 +136,9 @@ fun HENewTicketScreen( OutlinedTextField( value = subject, onValueChange = { subject = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = subjectLabel }, placeholder = { Text( text = stringResource(R.string.he_support_subject_placeholder) @@ -148,8 +153,9 @@ fun HENewTicketScreen( Spacer(modifier = Modifier.height(20.dp)) + val siteAddressLabel = stringResource(R.string.he_support_site_address_label) Text( - text = stringResource(R.string.he_support_site_address_label), + text = siteAddressLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp) @@ -158,7 +164,9 @@ fun HENewTicketScreen( OutlinedTextField( value = siteAddress, onValueChange = { siteAddress = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = siteAddressLabel }, placeholder = { Text( text = stringResource(R.string.he_support_site_address_placeholder) @@ -339,7 +347,9 @@ private fun CategoryOption( onClick: () -> Unit ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = label }, onClick = onClick, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( 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 93a75cb08257..73eae1aa236e 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 @@ -25,6 +25,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -48,8 +50,9 @@ fun TicketMainContentView( .fillMaxWidth() .padding(bottom = 32.dp) ) { + val messageLabel = stringResource(R.string.he_support_message_label) Text( - text = stringResource(R.string.he_support_message_label), + text = messageLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp) @@ -60,7 +63,8 @@ fun TicketMainContentView( onValueChange = { message -> onMessageChanged(message) }, modifier = Modifier .fillMaxWidth() - .height(200.dp), + .height(200.dp) + .semantics { contentDescription = messageLabel }, shape = RoundedCornerShape(12.dp), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), enabled = enabled, @@ -85,11 +89,13 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) + val addScreenshotsLabel = stringResource(R.string.he_support_add_screenshots_button) OutlinedButton( onClick = { /* Placeholder for add screenshots */ }, modifier = Modifier .fillMaxWidth() - .height(48.dp), + .height(48.dp) + .semantics { contentDescription = addScreenshotsLabel }, shape = RoundedCornerShape(12.dp), enabled = enabled, border = BorderStroke( @@ -108,7 +114,7 @@ fun TicketMainContentView( ) Spacer(modifier = Modifier.size(8.dp)) Text( - text = stringResource(R.string.he_support_add_screenshots_button), + text = addScreenshotsLabel, style = MaterialTheme.typography.labelLarge ) } @@ -122,6 +128,8 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) + val includeLogsLabel = stringResource(R.string.he_support_include_logs_title) + Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), @@ -141,7 +149,7 @@ fun TicketMainContentView( modifier = Modifier.weight(1f) ) { Text( - text = stringResource(R.string.he_support_include_logs_title), + text = includeLogsLabel, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 4.dp) @@ -159,7 +167,10 @@ fun TicketMainContentView( Switch( checked = includeAppLogs, onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) }, - enabled = enabled + enabled = enabled, + modifier = Modifier.semantics { + contentDescription = includeLogsLabel + } ) } } From 3edc398b656513286d054d8b4bbfeb4f2ae5f715 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 09:38:58 +0100 Subject: [PATCH 088/153] Adding headings labels --- .../android/support/he/ui/HENewTicketScreen.kt | 13 ++++++++++--- .../android/support/he/ui/TicketMainContentView.kt | 13 ++++++++++--- .../android/support/main/ui/SupportScreen.kt | 11 ++++++++++- 3 files changed, 30 insertions(+), 7 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 c28f1c163c2b..631ad3ca48e8 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization @@ -130,7 +131,9 @@ fun HENewTicketScreen( text = subjectLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( @@ -158,7 +161,9 @@ fun HENewTicketScreen( text = siteAddressLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( @@ -216,7 +221,9 @@ private fun SectionHeader( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, - modifier = modifier.padding(bottom = 16.dp) + modifier = modifier + .padding(bottom = 16.dp) + .semantics { heading() } ) } 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 73eae1aa236e..ef9a0ca6c911 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -55,7 +56,9 @@ fun TicketMainContentView( text = messageLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( @@ -79,7 +82,9 @@ fun TicketMainContentView( text = stringResource(R.string.he_support_screenshots_label), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 4.dp) + modifier = Modifier + .padding(bottom = 4.dp) + .semantics { heading() } ) Text( @@ -125,7 +130,9 @@ fun TicketMainContentView( text = stringResource(R.string.he_support_app_logs_label), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp) + modifier = Modifier + .padding(bottom = 12.dp) + .semantics { heading() } ) val includeLogsLabel = stringResource(R.string.he_support_include_logs_title) 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 4b0b5eeeb3e5..c9c1a4690ae7 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt @@ -26,6 +26,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -117,13 +120,15 @@ fun SupportScreen( } } } else { + val loginButtonText = stringResource(R.string.support_screen_login_button) Button( onClick = onLoginClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 24.dp) + .semantics { contentDescription = loginButtonText } ) { - Text(text = stringResource(R.string.support_screen_login_button)) + Text(text = loginButtonText) } } @@ -218,6 +223,7 @@ private fun SectionHeader( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { heading() } ) } @@ -232,6 +238,9 @@ private fun SupportOptionItem( .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 16.dp) + .semantics(mergeDescendants = true) { + contentDescription = "$title. $description" + } ) { Text( text = title, From ded3f34806b244a2c3c24a908f4ba5f2492e98fb Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 09:49:43 +0100 Subject: [PATCH 089/153] adding accessible labels to chat bubbles --- .../aibot/ui/AIBotConversationDetailScreen.kt | 32 ++++++++++++++++--- .../he/ui/HEConversationDetailScreen.kt | 32 ++++++++++++++++--- 2 files changed, 54 insertions(+), 10 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 ab8386bdcf7b..da55408798fc 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 @@ -40,6 +40,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -195,10 +198,17 @@ fun AIBotConversationDetailScreen( @Composable private fun WelcomeHeader(userName: String) { + val greeting = stringResource(R.string.ai_bot_welcome_greeting, userName) + val message = stringResource(R.string.ai_bot_welcome_message) + val welcomeDescription = "$greeting. $message" + Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .semantics(mergeDescendants = true) { + contentDescription = welcomeDescription + }, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), @@ -220,7 +230,8 @@ private fun WelcomeHeader(userName: String) { text = stringResource(R.string.ai_bot_welcome_greeting, userName), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() } ) Text( @@ -241,6 +252,7 @@ private fun ChatInputBar( onSendClick: () -> Unit ) { val canSend = messageText.isNotBlank() && canSendMessage + val messageInputLabel = stringResource(R.string.ai_bot_message_input_placeholder) Row( modifier = Modifier @@ -253,8 +265,10 @@ private fun ChatInputBar( OutlinedTextField( value = messageText, onValueChange = onMessageTextChange, - modifier = Modifier.weight(1f), - placeholder = { Text(stringResource(R.string.ai_bot_message_input_placeholder)) }, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = messageInputLabel }, + placeholder = { Text(messageInputLabel) }, maxLines = 4, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) @@ -278,6 +292,10 @@ private fun ChatInputBar( @Composable private fun MessageBubble(message: BotMessage, resources: android.content.res.Resources) { + val timestamp = formatRelativeTime(message.date, resources) + val author = if (message.isWrittenByUser) "You" else "AI Bot" + val messageDescription = "$author, $timestamp. ${message.formattedText}" + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (message.isWrittenByUser) { @@ -303,6 +321,9 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re ) ) .padding(12.dp) + .semantics(mergeDescendants = true) { + contentDescription = messageDescription + } ) { Column { Text( @@ -318,7 +339,7 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re Spacer(modifier = Modifier.height(4.dp)) Text( - text = formatRelativeTime(message.date, resources), + text = timestamp, style = MaterialTheme.typography.bodySmall, color = if (message.isWrittenByUser) { MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) @@ -349,6 +370,7 @@ private fun TypingIndicatorBubble() { ) ) .padding(16.dp) + .semantics { contentDescription = "AI Bot is typing" } ) { Row( horizontalArrangement = Arrangement.spacedBy(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..af2fc2a7c8cf 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 @@ -46,6 +46,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -188,10 +191,19 @@ private fun ConversationHeader( lastUpdated: String, isLoading: Boolean = false ) { + val headerDescription = if (!isLoading) { + "${stringResource(R.string.he_support_message_count, messageCount)}. ${stringResource(R.string.he_support_last_updated, lastUpdated)}" + } else { + stringResource(R.string.he_support_last_updated, lastUpdated) + } + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .semantics(mergeDescendants = true) { + contentDescription = headerDescription + }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -235,7 +247,8 @@ private fun ConversationTitleCard(title: String) { text = title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() } ) } } @@ -245,6 +258,8 @@ private fun MessageItem( message: SupportMessage, timestamp: String ) { + val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" + Box( modifier = Modifier .fillMaxWidth() @@ -257,6 +272,9 @@ private fun MessageItem( shape = RoundedCornerShape(8.dp) ) .padding(16.dp) + .semantics(mergeDescendants = true) { + contentDescription = messageDescription + } ) { Column( modifier = Modifier.fillMaxWidth() @@ -300,6 +318,8 @@ private fun ReplyButton( enabled: Boolean = true, onClick: () -> Unit ) { + val replyButtonLabel = stringResource(R.string.he_support_reply_button) + Box( modifier = Modifier .fillMaxWidth() @@ -310,7 +330,8 @@ private fun ReplyButton( enabled = enabled, modifier = Modifier .fillMaxWidth() - .height(56.dp), + .height(56.dp) + .semantics { contentDescription = replyButtonLabel }, shape = RoundedCornerShape(28.dp) ) { Icon( @@ -320,7 +341,7 @@ private fun ReplyButton( ) Spacer(modifier = Modifier.size(8.dp)) Text( - text = stringResource(R.string.he_support_reply_button), + text = replyButtonLabel, style = MaterialTheme.typography.titleMedium ) } @@ -394,7 +415,8 @@ private fun ReplyBottomSheet( Text( text = stringResource(R.string.he_support_reply_button), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() } ) TextButton( From d8c4468a7321da04c71e2ed8de56f543d0ee8cf2 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 09:52:33 +0100 Subject: [PATCH 090/153] detekt --- .../aibot/repository/AIBotSupportRepository.kt | 5 ++--- .../aibot/ui/AIBotConversationsListScreen.kt | 12 ------------ .../support/he/ui/HEConversationDetailScreen.kt | 3 ++- .../support/he/ui/HEConversationsListScreen.kt | 13 ------------- 4 files changed, 4 insertions(+), 29 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 6bd408722719..86b37527a7ac 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt @@ -20,6 +20,7 @@ import javax.inject.Inject import javax.inject.Named private const val BOT_ID = "jetpack-chat-mobile" +private const val ITEMS_PER_PAGE = 20 class AIBotSupportRepository @Inject constructor( private val appLogWrapper: AppLogWrapper, @@ -75,9 +76,7 @@ class AIBotSupportRepository @Inject constructor( chatId = chatId.toULong(), params = GetBotConversationParams( pageNumber = pageNumber.toULong(), - // TODO: this is set to 4 for testing purpose - // The TODO is preventing the Pr to be merged. Change it to a higher number before that - itemsPerPage = 4U + itemsPerPage = ITEMS_PER_PAGE.toULong() ) ) } 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 e9f73df1a63b..2289283ccb93 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt @@ -5,25 +5,13 @@ import android.content.res.Resources import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment 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 af2fc2a7c8cf..2d874a5f10f3 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 @@ -192,7 +192,8 @@ private fun ConversationHeader( isLoading: Boolean = false ) { val headerDescription = if (!isLoading) { - "${stringResource(R.string.he_support_message_count, messageCount)}. ${stringResource(R.string.he_support_last_updated, lastUpdated)}" + "${stringResource(R.string.he_support_message_count, messageCount)}. " + + stringResource(R.string.he_support_last_updated, lastUpdated) } else { stringResource(R.string.he_support_last_updated, lastUpdated) } 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 170fc9f0b151..70f106c36bea 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt @@ -6,23 +6,13 @@ 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 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.filled.Edit import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -38,11 +28,8 @@ import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.common.ui.ConversationsListScreen import org.wordpress.android.support.common.ui.ConversationsSupportViewModel -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 -import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) From f97d4e8e9e9b25db25a9e8807b35308460f33a31 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 10:44:19 +0100 Subject: [PATCH 091/153] Fixing tests --- .../aibot/ui/AIBotSupportViewModelTest.kt | 7 +++++- .../ui/ConversationsSupportViewModelTest.kt | 22 +++++++++++++------ .../support/he/ui/HESupportViewModelTest.kt | 7 +++++- 3 files changed, 27 insertions(+), 9 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 ff35ecce2881..2a1614c07213 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 @@ -18,6 +18,7 @@ import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage import org.wordpress.android.support.aibot.repository.AIBotSupportRepository import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date @ExperimentalCoroutinesApi @@ -31,6 +32,9 @@ class AIBotSupportViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + private lateinit var viewModel: AIBotSupportViewModel private val testAccessToken = "test_access_token" @@ -55,7 +59,8 @@ class AIBotSupportViewModelTest : BaseUnitTest() { viewModel = AIBotSupportViewModel( accountStore = accountStore, aiBotSupportRepository = aiBotSupportRepository, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) } diff --git a/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt index 90bfe32ae9d1..03f47f2791e9 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt @@ -14,6 +14,8 @@ import org.wordpress.android.fluxc.model.AccountModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.model.Conversation +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel.ConversationsState +import org.wordpress.android.util.NetworkUtilsWrapper @ExperimentalCoroutinesApi class ConversationsSupportViewModelTest : BaseUnitTest() { @@ -23,6 +25,9 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + private lateinit var viewModel: TestConversationsSupportViewModel private val testAccessToken = "test_access_token" @@ -41,10 +46,12 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = TestConversationsSupportViewModel( accountStore = accountStore, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) } @@ -60,7 +67,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { assertThat(viewModel.initRepositoryCalled).isTrue assertThat(viewModel.conversations.value).isEqualTo(testConversations) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Loaded.javaClass) assertThat(viewModel.errorMessage.value).isNull() } @@ -137,7 +144,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { advanceUntilIdle() assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) verify(appLogWrapper).e(any(), any()) } @@ -157,7 +164,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { advanceUntilIdle() assertThat(viewModel.conversations.value).isEqualTo(updatedConversations) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Loaded.javaClass) } @Test @@ -170,7 +177,7 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { advanceUntilIdle() assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) - assertThat(viewModel.isLoadingConversations.value).isFalse + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) } // Clear Error Tests @@ -350,8 +357,9 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { private class TestConversationsSupportViewModel( accountStore: AccountStore, - appLogWrapper: AppLogWrapper - ) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + appLogWrapper: AppLogWrapper, + networkUtilsWrapper: NetworkUtilsWrapper + ) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { var initRepositoryCalled = false private var shouldThrowOnInit = false private var shouldThrowOnGetConversations = false diff --git a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt index 6497ec6dabd4..68dbfef25123 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 @@ -19,6 +19,7 @@ import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository +import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date @ExperimentalCoroutinesApi @@ -32,6 +33,9 @@ class HESupportViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + private lateinit var viewModel: HESupportViewModel private val testAccessToken = "test_access_token" @@ -56,7 +60,8 @@ class HESupportViewModelTest : BaseUnitTest() { viewModel = HESupportViewModel( accountStore = accountStore, heSupportRepository = heSupportRepository, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) } From 1294e3de44f6486058f1adbabfb94f207d8d99b9 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 10:50:20 +0100 Subject: [PATCH 092/153] PR suggestion about bot chat bubble --- .../android/support/aibot/ui/AIBotConversationDetailScreen.kt | 2 +- WordPress/src/main/res/values/strings.xml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 da55408798fc..e60338b85b54 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 @@ -293,7 +293,7 @@ private fun ChatInputBar( @Composable private fun MessageBubble(message: BotMessage, resources: android.content.res.Resources) { val timestamp = formatRelativeTime(message.date, resources) - val author = if (message.isWrittenByUser) "You" else "AI Bot" + val author = stringResource(if (message.isWrittenByUser) R.string.ai_bot_you else R.string.ai_bot_odie) val messageDescription = "$author, $timestamp. ${message.formattedText}" Row( diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 57ea0456bf20..1a8c9ca316f2 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5130,6 +5130,8 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> No conversations yet Start a new conversation to get help with your WordPress site or account. Start conversation + You + Odie Send From f8dc40ec9966f64fac18cb969ff8afd17536015a Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 12:24:14 +0100 Subject: [PATCH 093/153] Fixing tests --- .../android/support/aibot/ui/AIBotSupportViewModelTest.kt | 1 + .../wordpress/android/support/he/ui/HESupportViewModelTest.kt | 1 + 2 files changed, 2 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 2a1614c07213..beaaa821b103 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt @@ -55,6 +55,7 @@ class AIBotSupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = AIBotSupportViewModel( accountStore = accountStore, 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 68dbfef25123..acbe3ef97efc 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt @@ -56,6 +56,7 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = HESupportViewModel( accountStore = accountStore, From 983ea0dc2f9fcba9b1e45b83ee276ac1f7810ba6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 16:32:25 +0100 Subject: [PATCH 094/153] Updating rust --- gradle/libs.versions.toml | 2 +- .../network/rest/wpapi/media/MediaRSApiRestClient.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e68832efc304..8b4e9bdd9ee1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-a0864c91b8dc3726b0ad43e22662c4415aca59ce' +wordpress-rs = 'trunk-59989859de3621982636bc413142d86f884f5837' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1' diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt index 568679c0c3e5..808b495befb4 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt @@ -256,10 +256,10 @@ class MediaRSApiRestClient @Inject constructor( val mediaResponse = client.request { requestBuilder -> requestBuilder.media().create( - params = MediaCreateParams(title = media.title), - filePath = filePath, - fileContentType = media.mimeType.orEmpty(), - requestId = null + params = MediaCreateParams( + title = media.title, + filePath = filePath, + ), ) } From 471312e3ab849cb418e23d9a8730d440e039de4a Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 17:18:44 +0100 Subject: [PATCH 095/153] Adding attachments UI --- .../support/he/ui/HENewTicketScreen.kt | 19 +++- .../support/he/ui/HESupportActivity.kt | 6 +- .../android/support/he/ui/SupportCategory.kt | 14 +-- .../support/he/ui/TicketMainContentView.kt | 105 ++++++++++++++++-- 4 files changed, 121 insertions(+), 23 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 631ad3ca48e8..16a1aea15031 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 @@ -68,11 +68,15 @@ fun HENewTicketScreen( subject: String, messageText: String, siteAddress: String, + attachments: List ) -> Unit, userName: String = "", userEmail: String = "", userAvatarUrl: String? = null, - isSendingNewConversation: Boolean = false + isSendingNewConversation: Boolean = false, + onAddImageClick: () -> Unit = { }, + selectedImagePaths: List = emptyList(), + onRemoveImage: (String) -> Unit = { } ) { var selectedCategory by remember { mutableStateOf(null) } var subject by remember { mutableStateOf("") } @@ -95,7 +99,7 @@ fun HENewTicketScreen( isLoading = isSendingNewConversation, onClick = { selectedCategory?.let { category -> - onSubmit(category, subject, messageText, siteAddress) + onSubmit(category, subject, messageText, siteAddress, selectedImagePaths) } } ) @@ -194,6 +198,9 @@ fun HENewTicketScreen( includeAppLogs = includeAppLogs, onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, + selectedImagePaths = selectedImagePaths, + onAddImageClick = onAddImageClick, + onRemoveImage = onRemoveImage ) Spacer(modifier = Modifier.height(32.dp)) @@ -418,7 +425,7 @@ private fun HENewTicketScreenPreview() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _ -> }, + onSubmit = { _, _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -434,7 +441,7 @@ private fun HENewTicketScreenPreviewDark() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _ -> }, + onSubmit = { _, _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -450,7 +457,7 @@ private fun HENewTicketScreenWordPressPreview() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _ -> }, + onSubmit = { _, _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -466,7 +473,7 @@ private fun HENewTicketScreenPreviewWordPressDark() { HENewTicketScreen( snackbarHostState = snackbarHostState, 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 171abf010aa4..6124c49a0ec1 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 @@ -157,12 +157,12 @@ class HESupportActivity : AppCompatActivity() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { viewModel.onBackClick() }, - onSubmit = { category, subject, messageText, siteAddress -> + onSubmit = { category, subject, messageText, siteAddress, attachments -> viewModel.onSendNewConversation( subject = subject, message = messageText, - tags = listOf(category.name), - attachments = listOf() + tags = listOf(category.key), + attachments = attachments ) }, userName = userInfo.userName, 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 index 6a5240f5f44c..596534026f79 100644 --- 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 @@ -9,11 +9,11 @@ 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) +enum class SupportCategory(val icon: ImageVector, val labelRes: Int, val key: String) { + APPLICATION(Icons.Default.PhoneAndroid, R.string.he_support_category_application, "application"), + JETPACK_CONNECTION(Icons.Default.Settings, R.string.he_support_category_jetpack_connection, "jetpack-connection"), + SITE_MANAGEMENT(Icons.Default.Language, R.string.he_support_category_site_management, "site-management"), + BILLING(Icons.Default.CreditCard, R.string.he_support_category_billing, "billing-subscriptions"), + TECHNICAL_ISSUES(Icons.Default.Settings, R.string.he_support_category_technical_issues, "technical-issues"), + OTHER(Icons.AutoMirrored.Filled.Help, R.string.he_support_category_other, "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 ef9a0ca6c911..69e3b3bfd352 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 @@ -3,13 +3,16 @@ 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.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material3.Card @@ -33,7 +36,13 @@ 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.material.icons.filled.Close +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -44,8 +53,11 @@ fun TicketMainContentView( includeAppLogs: Boolean, onMessageChanged: (String) -> Unit, onIncludeAppLogsChanged: (Boolean) -> Unit, - enabled: Boolean = true -) { + enabled: Boolean = true, + selectedImagePaths: List = emptyList(), + onAddImageClick: () -> Unit = { }, + onRemoveImage: (String) -> Unit = { }, + ) { Column( modifier = Modifier .fillMaxWidth() @@ -94,18 +106,39 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) + if (selectedImagePaths.isNotEmpty()) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + selectedImagePaths.forEach { imagePath -> + ImagePreviewItem( + imagePath = imagePath, + onRemove = { onRemoveImage(imagePath) }, + enabled = enabled + ) + } + } + } + + val maxImagesReached = selectedImagePaths.size >= 4 + + val addScreenshotsLabel = stringResource(R.string.he_support_add_screenshots_button) OutlinedButton( - onClick = { /* Placeholder for add screenshots */ }, + onClick = onAddImageClick, modifier = Modifier .fillMaxWidth() .height(48.dp) .semantics { contentDescription = addScreenshotsLabel }, shape = RoundedCornerShape(12.dp), - enabled = enabled, + enabled = enabled && !maxImagesReached, border = BorderStroke( width = 1.dp, - color = if (enabled) { + color = if (enabled && !maxImagesReached) { MaterialTheme.colorScheme.outline } else { MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) @@ -184,9 +217,67 @@ fun TicketMainContentView( } } +@Composable +private fun ImagePreviewItem( + imagePath: String, + onRemove: () -> Unit, + enabled: Boolean = true +) { + Box( + modifier = Modifier + .size(100.dp) + ) { + Card( + modifier = Modifier.size(100.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp + ) + ) { + AsyncImage( + model = imagePath, + contentDescription = "Screenshot preview", + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + } + + if (enabled) { + Surface( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(28.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 4.dp, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + ) + ) { + IconButton( + onClick = onRemove, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove screenshot", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } + } + } +} + + @Preview(showBackground = true, name = "HE main ticket content") @Composable -private fun ReplyBottomSheetPreview() { +private fun TicketMainContentViewPreview() { AppThemeM3(isDarkTheme = false) { TicketMainContentView( messageText = "", @@ -199,7 +290,7 @@ private fun ReplyBottomSheetPreview() { @Preview(showBackground = true, name = "HE main ticket content - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable -private fun ReplyBottomSheetPreviewDark() { +private fun TicketMainContentViewPreviewDark() { AppThemeM3(isDarkTheme = true) { TicketMainContentView( messageText = "", From 4744542ce1b306e7dbc9925d1d03e6f8bacb72eb Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 17:59:52 +0100 Subject: [PATCH 096/153] 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 097/153] 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 098/153] 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") From 831447211dfe406cb93e86b53ebbb02314aad1bb Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 28 Oct 2025 18:54:41 +0100 Subject: [PATCH 099/153] Supporting in conversation as well --- .../he/ui/HEConversationDetailScreen.kt | 32 ++++++--- .../support/he/ui/HESupportActivity.kt | 65 +++++++++++++++++-- 2 files changed, 82 insertions(+), 15 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 2d874a5f10f3..f174711c6f87 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 @@ -70,8 +70,11 @@ fun HEConversationDetailScreen( isSendingMessage: Boolean = false, messageSendResult: HESupportViewModel.MessageSendResult? = null, onBackClick: () -> Unit, - onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit, - onClearMessageSendResult: () -> Unit = {} + onSendMessage: (message: String, includeAppLogs: Boolean, attachments: List) -> Unit, + onClearMessageSendResult: () -> Unit = {}, + onAddImageClick: () -> Unit = {}, + selectedImagePaths: List = emptyList(), + onRemoveImage: (String) -> Unit = {} ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -173,14 +176,17 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> - onSendMessage(message, includeAppLogs) + onSendMessage(message, includeAppLogs, selectedImagePaths) }, onMessageSentSuccessfully = { // Clear draft after successful send draftMessageText = "" draftIncludeAppLogs = false onClearMessageSendResult() - } + }, + onAddImageClick = onAddImageClick, + selectedImagePaths = selectedImagePaths, + onRemoveImage = onRemoveImage ) } } @@ -359,7 +365,10 @@ private fun ReplyBottomSheet( initialIncludeAppLogs: Boolean = false, onDismiss: (currentMessage: String, currentIncludeAppLogs: Boolean) -> Unit, onSend: (String, Boolean) -> Unit, - onMessageSentSuccessfully: () -> Unit + onMessageSentSuccessfully: () -> Unit, + onAddImageClick: () -> Unit = {}, + selectedImagePaths: List = emptyList(), + onRemoveImage: (String) -> Unit = {} ) { var messageText by remember { mutableStateOf(initialMessageText) } var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } @@ -443,7 +452,10 @@ private fun ReplyBottomSheet( includeAppLogs = includeAppLogs, onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, - enabled = !isSending + enabled = !isSending, + selectedImagePaths = selectedImagePaths, + onAddImageClick = onAddImageClick, + onRemoveImage = onRemoveImage ) } } @@ -460,7 +472,7 @@ private fun HEConversationDetailScreenPreview() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + onSendMessage = { _, _, _ -> } ) } } @@ -476,7 +488,7 @@ private fun HEConversationDetailScreenPreviewDark() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + onSendMessage = { _, _, _ -> } ) } } @@ -492,7 +504,7 @@ private fun HEConversationDetailScreenWordPressPreview() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + onSendMessage = { _, _, _ -> } ) } } @@ -509,7 +521,7 @@ private fun HEConversationDetailScreenPreviewWordPressDark() { isLoading = true, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + 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 6124c49a0ec1..e7b5b68374f6 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 @@ -2,17 +2,23 @@ package org.wordpress.android.support.he.ui import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle @@ -132,6 +138,25 @@ class HESupportActivity : AppCompatActivity() { val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isSendingMessage by viewModel.isSendingMessage.collectAsState() val messageSendResult by viewModel.messageSendResult.collectAsState() + var selectedImageUris by remember { mutableStateOf>(emptyList()) } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris: List -> + // Only add images if we haven't reached the limit of 4 + val availableSlots = 4 - selectedImageUris.size + if (availableSlots > 0) { + selectedImageUris = selectedImageUris + uris.take(availableSlots) + } + } + + // Clear images after successful message send + LaunchedEffect(messageSendResult) { + if (messageSendResult is HESupportViewModel.MessageSendResult.Success) { + selectedImageUris = emptyList() + } + } + selectedConversation?.let { conversation -> HEConversationDetailScreen( snackbarHostState = snackbarHostState, @@ -140,13 +165,22 @@ class HESupportActivity : AppCompatActivity() { isSendingMessage = isSendingMessage, messageSendResult = messageSendResult, onBackClick = { viewModel.onBackClick() }, - onSendMessage = { message, includeAppLogs -> + onSendMessage = { message, includeAppLogs, attachments -> viewModel.onAddMessageToConversation( message = message, - attachments = emptyList() + attachments = attachments ) }, - onClearMessageSendResult = { viewModel.clearMessageSendResult() } + onClearMessageSendResult = { viewModel.clearMessageSendResult() }, + onAddImageClick = { + if (selectedImageUris.size < 4) { + imagePickerLauncher.launch("image/*") + } + }, + selectedImagePaths = selectedImageUris.map { it.toString() }, + onRemoveImage = { pathToRemove -> + selectedImageUris = selectedImageUris.filter { it.toString() != pathToRemove } + } ) } } @@ -154,6 +188,18 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.NewTicket.name) { val userInfo by viewModel.userInfo.collectAsState() val isSendingNewConversation by viewModel.isSendingMessage.collectAsState() + var selectedImageUris by remember { mutableStateOf>(emptyList()) } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris: List -> + // Only add images if we haven't reached the limit of 4 + val availableSlots = 4 - selectedImageUris.size + if (availableSlots > 0) { + selectedImageUris = selectedImageUris + uris.take(availableSlots) + } + } + HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { viewModel.onBackClick() }, @@ -168,8 +214,17 @@ class HESupportActivity : AppCompatActivity() { userName = userInfo.userName, userEmail = userInfo.userEmail, userAvatarUrl = userInfo.avatarUrl, - isSendingNewConversation = isSendingNewConversation - ) + isSendingNewConversation = isSendingNewConversation, + onAddImageClick = { + if (selectedImageUris.size < 4) { + imagePickerLauncher.launch("image/*") + } + }, + selectedImagePaths = selectedImageUris.map { it.toString() }, + onRemoveImage = { pathToRemove -> + selectedImageUris = selectedImageUris.filter { it.toString() != pathToRemove } + }, + ) } } } From af50df592a502851f50a9dda8c2e865f7f321482 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 29 Oct 2025 10:37:59 +0100 Subject: [PATCH 100/153] Keeping the screen when select images --- .../support/he/ui/HESupportActivity.kt | 148 +++++++++++++----- 1 file changed, 109 insertions(+), 39 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 e7b5b68374f6..26a67031ab73 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,9 +4,8 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import androidx.core.net.toUri import android.os.Bundle -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.SnackbarDuration @@ -33,14 +32,28 @@ import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.ui.photopicker.MediaPickerLauncher +import org.wordpress.android.ui.photopicker.MediaPickerConstants +import org.wordpress.android.ui.RequestCodes +import org.wordpress.android.ui.media.MediaBrowserType +import javax.inject.Inject @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { + @Inject lateinit var mediaPickerLauncher: MediaPickerLauncher private val viewModel by viewModels() private lateinit var composeView: ComposeView private lateinit var navController: NavHostController + // State for selected images in Detail screen + private var selectedDetailImageUris by mutableStateOf>(emptyList()) + private var selectedDetailImagePaths by mutableStateOf>(emptyList()) + + // State for selected images in NewTicket screen + private var selectedNewTicketImageUris by mutableStateOf>(emptyList()) + private var selectedNewTicketImagePaths by mutableStateOf>(emptyList()) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) composeView = ComposeView(this) @@ -80,6 +93,66 @@ class HESupportActivity : AppCompatActivity() { } } + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (resultCode == RESULT_OK && data != null) { + when (requestCode) { + RequestCodes.PHOTO_PICKER -> { + // Handle media picker result based on current screen + val uris = data.getStringArrayExtra(MediaPickerConstants.EXTRA_MEDIA_URIS) + uris?.let { uriStrings -> + lifecycleScope.launch { + val newUris = uriStrings.map { it.toUri() } + + // Determine which screen is active by checking current destination + val currentDestination = navController.currentDestination?.route + + when (currentDestination) { + ConversationScreen.Detail.name -> { + // Convert URIs to file paths + val newPaths = newUris.mapNotNull { uri -> + copyUriToTempFile(uri)?.absolutePath + } + // Update state immutably to trigger recomposition + selectedDetailImageUris = selectedDetailImageUris + newUris + selectedDetailImagePaths = selectedDetailImagePaths + newPaths + } + ConversationScreen.NewTicket.name -> { + // Convert URIs to file paths + val newPaths = newUris.mapNotNull { uri -> + copyUriToTempFile(uri)?.absolutePath + } + // Update state immutably to trigger recomposition + selectedNewTicketImageUris = selectedNewTicketImageUris + newUris + selectedNewTicketImagePaths = selectedNewTicketImagePaths + newPaths + } + } + } + } + } + } + } + } + + private fun copyUriToTempFile(uri: Uri): java.io.File? { + return try { + val inputStream = contentResolver.openInputStream(uri) ?: return null + val fileName = "support_image_${System.currentTimeMillis()}.jpg" + val tempFile = java.io.File(cacheDir, fileName) + + tempFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + inputStream.close() + + tempFile + } catch (e: Exception) { + null + } + } + private enum class ConversationScreen { List, Detail, @@ -138,22 +211,12 @@ class HESupportActivity : AppCompatActivity() { val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isSendingMessage by viewModel.isSendingMessage.collectAsState() val messageSendResult by viewModel.messageSendResult.collectAsState() - var selectedImageUris by remember { mutableStateOf>(emptyList()) } - - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetMultipleContents() - ) { uris: List -> - // Only add images if we haven't reached the limit of 4 - val availableSlots = 4 - selectedImageUris.size - if (availableSlots > 0) { - selectedImageUris = selectedImageUris + uris.take(availableSlots) - } - } // Clear images after successful message send LaunchedEffect(messageSendResult) { if (messageSendResult is HESupportViewModel.MessageSendResult.Success) { - selectedImageUris = emptyList() + selectedDetailImageUris = emptyList() + selectedDetailImagePaths = emptyList() } } @@ -165,21 +228,30 @@ class HESupportActivity : AppCompatActivity() { isSendingMessage = isSendingMessage, messageSendResult = messageSendResult, onBackClick = { viewModel.onBackClick() }, - onSendMessage = { message, includeAppLogs, attachments -> + onSendMessage = { message, includeAppLogs, _ -> viewModel.onAddMessageToConversation( message = message, - attachments = attachments + attachments = selectedDetailImagePaths ) }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, onAddImageClick = { - if (selectedImageUris.size < 4) { - imagePickerLauncher.launch("image/*") + if (selectedDetailImageUris.size < 4) { + mediaPickerLauncher.showPhotoPickerForResult( + activity = this@HESupportActivity, + browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, + site = null, + localPostId = null + ) } }, - selectedImagePaths = selectedImageUris.map { it.toString() }, - onRemoveImage = { pathToRemove -> - selectedImageUris = selectedImageUris.filter { it.toString() != pathToRemove } + selectedImagePaths = selectedDetailImageUris.map { it.toString() }, + onRemoveImage = { uriString -> + val index = selectedDetailImageUris.indexOfFirst { it.toString() == uriString } + if (index >= 0) { + selectedDetailImageUris = selectedDetailImageUris.filterIndexed { i, _ -> i != index } + selectedDetailImagePaths = selectedDetailImagePaths.filterIndexed { i, _ -> i != index } + } } ) } @@ -188,27 +260,16 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.NewTicket.name) { val userInfo by viewModel.userInfo.collectAsState() val isSendingNewConversation by viewModel.isSendingMessage.collectAsState() - var selectedImageUris by remember { mutableStateOf>(emptyList()) } - - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetMultipleContents() - ) { uris: List -> - // Only add images if we haven't reached the limit of 4 - val availableSlots = 4 - selectedImageUris.size - if (availableSlots > 0) { - selectedImageUris = selectedImageUris + uris.take(availableSlots) - } - } HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { viewModel.onBackClick() }, - onSubmit = { category, subject, messageText, siteAddress, attachments -> + onSubmit = { category, subject, messageText, siteAddress, _ -> viewModel.onSendNewConversation( subject = subject, message = messageText, tags = listOf(category.key), - attachments = attachments + attachments = selectedNewTicketImagePaths ) }, userName = userInfo.userName, @@ -216,13 +277,22 @@ class HESupportActivity : AppCompatActivity() { userAvatarUrl = userInfo.avatarUrl, isSendingNewConversation = isSendingNewConversation, onAddImageClick = { - if (selectedImageUris.size < 4) { - imagePickerLauncher.launch("image/*") + if (selectedNewTicketImageUris.size < 4) { + mediaPickerLauncher.showPhotoPickerForResult( + activity = this@HESupportActivity, + browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, + site = null, + localPostId = null + ) } }, - selectedImagePaths = selectedImageUris.map { it.toString() }, - onRemoveImage = { pathToRemove -> - selectedImageUris = selectedImageUris.filter { it.toString() != pathToRemove } + selectedImagePaths = selectedNewTicketImageUris.map { it.toString() }, + onRemoveImage = { uriString -> + val index = selectedNewTicketImageUris.indexOfFirst { it.toString() == uriString } + if (index >= 0) { + selectedNewTicketImageUris = selectedNewTicketImageUris.filterIndexed { i, _ -> i != index } + selectedNewTicketImagePaths = selectedNewTicketImagePaths.filterIndexed { i, _ -> i != index } + } }, ) } From 8223d9e01ee0f44a00e6a15380a73e79381e0beb Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 29 Oct 2025 10:48:41 +0100 Subject: [PATCH 101/153] Add attachments to the message data class --- .../support/he/model/SupportMessage.kt | 13 +++++- .../he/repository/HESupportRepository.kt | 35 ++++++++------ .../support/he/util/HEConversationUtils.kt | 46 ++++++++++++++++--- 3 files changed, 74 insertions(+), 20 deletions(-) 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 ae76fdb21a42..969d121c5b35 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 @@ -2,6 +2,7 @@ package org.wordpress.android.support.he.model import androidx.compose.runtime.Immutable import androidx.compose.ui.text.AnnotatedString +import uniffi.wp_api.AttachmentMetadataValue import java.util.Date @Immutable @@ -11,5 +12,15 @@ data class SupportMessage( val formattedText: AnnotatedString, val createdAt: Date, val authorName: String, - val authorIsUser: Boolean + val authorIsUser: Boolean, + val attachments: List, ) + +data class SupportAttachment ( + val id: Long, + val filename: String, + val url: String, + val type: AttachmentType, +) + +enum class AttachmentType { Image, Video, Other } 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 be88432043f9..97bcfc44e8af 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,6 +5,7 @@ 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.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString @@ -185,23 +186,31 @@ class HESupportRepository @Inject constructor( 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() } + id = id.toLong(), + title = title, + description = description, + lastMessageSentAt = updatedAt, + messages = messages.map { it.toSupportMessage() } ) private fun uniffi.wp_api.SupportMessage.toSupportMessage(): SupportMessage = SupportMessage( - id = this.id.toLong(), - rawText = this.content, - formattedText = markdownToAnnotatedString(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 + id = id.toLong(), + rawText = content, + formattedText = markdownToAnnotatedString(content), + createdAt = createdAt, + authorName = when (author) { + is SupportMessageAuthor.User -> (author as SupportMessageAuthor.User).v1.displayName + is SupportMessageAuthor.SupportAgent -> (author as SupportMessageAuthor.SupportAgent).v1.name }, - authorIsUser = this.authorIsCurrentUser + authorIsUser = authorIsCurrentUser, + attachments = attachments.map { it.toSupportAttachment() } + ) + + private fun uniffi.wp_api.SupportAttachment.toSupportAttachment(): SupportAttachment = + SupportAttachment( + id = id.toLong(), + filename = filename, + url = url, ) } 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 90963637c132..f662d8c0bf70 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,6 +1,8 @@ package org.wordpress.android.support.he.util import androidx.compose.ui.text.AnnotatedString +import org.wordpress.android.support.he.model.AttachmentType +import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import java.util.Date @@ -27,7 +29,21 @@ fun generateSampleHESupportConversations(): List { "the past few days."), createdAt = Date(oneHourAgo.time - 1800000), authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = listOf( + SupportAttachment( + id = 1, + filename = "screenshot.png", + url = "https://example.com/attachments/screenshot.png", + type = AttachmentType.Image + ), + SupportAttachment( + id = 2, + filename = "error-log.txt", + url = "https://example.com/attachments/error-log.txt", + type = AttachmentType.Other + ) + ) ), SupportMessage( id = 2, @@ -36,7 +52,8 @@ fun generateSampleHESupportConversations(): List { "Can you share your site URL?"), createdAt = Date(oneHourAgo.time - 900000), authorName = "Support Agent", - authorIsUser = false + authorIsUser = false, + attachments = emptyList() ), SupportMessage( id = 3, @@ -44,7 +61,8 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("Sure, it's example.wordpress.com"), createdAt = oneHourAgo, authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = emptyList() ) ) ), @@ -62,7 +80,8 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I'm trying to install a new plugin but getting an error."), createdAt = Date(twoDaysAgo.time - 3600000), authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = emptyList() ), SupportMessage( id = 5, @@ -70,7 +89,8 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I can help with that! What's the error message you're seeing?"), createdAt = twoDaysAgo, authorName = "Support Agent", - authorIsUser = false + authorIsUser = false, + attachments = emptyList() ) ) ), @@ -88,7 +108,21 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I need help setting up my custom domain."), createdAt = oneWeekAgo, authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = listOf( + SupportAttachment( + id = 3, + filename = "domain-settings.pdf", + url = "https://example.com/attachments/domain-settings.pdf", + type = AttachmentType.Other + ), + SupportAttachment( + id = 4, + filename = "setup-tutorial.mp4", + url = "https://example.com/attachments/setup-tutorial.mp4", + type = AttachmentType.Video + ) + ) ) ) ) From 2b7532777740d7bfe3964575112903224f720fd3 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 29 Oct 2025 11:30:44 +0100 Subject: [PATCH 102/153] Showing attachments in the UI --- .../he/repository/HESupportRepository.kt | 10 +++ .../he/ui/HEConversationDetailScreen.kt | 70 +++++++++++++++++++ 2 files changed, 80 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 97bcfc44e8af..e107d19c4300 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,6 +5,7 @@ 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.he.model.AttachmentType import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage @@ -212,5 +213,14 @@ class HESupportRepository @Inject constructor( id = id.toLong(), filename = filename, url = url, + type = determineAttachmentType(contentType) ) + + private fun determineAttachmentType(contentType: String): AttachmentType { + return when { + contentType.startsWith("image/") -> AttachmentType.Image + contentType.startsWith("video/") -> AttachmentType.Video + else -> AttachmentType.Other + } + } } 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 f174711c6f87..834dd0ee4c5d 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 @@ -1,7 +1,10 @@ package org.wordpress.android.support.he.ui +import android.content.Intent import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.core.net.toUri import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +16,7 @@ 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -43,6 +47,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.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -316,10 +321,75 @@ private fun MessageItem( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) + + // Display attachments if present + if (message.attachments.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + AttachmentsList(attachments = message.attachments) + } } } } +@Composable +private fun AttachmentsList(attachments: List) { + val context = LocalContext.current + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + attachments.forEach { attachment -> + AttachmentItem( + attachment = attachment, + onClick = { + // TODO: open the attachment + } + ) + } + } +} + +@Composable +private fun AttachmentItem( + attachment: org.wordpress.android.support.he.model.SupportAttachment, + onClick: () -> Unit +) { + val iconRes = when (attachment.type) { + org.wordpress.android.support.he.model.AttachmentType.Image -> R.drawable.ic_image_white_24dp + org.wordpress.android.support.he.model.AttachmentType.Video -> R.drawable.ic_video_camera_white_24dp + org.wordpress.android.support.he.model.AttachmentType.Other -> R.drawable.ic_pages_white_24dp + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = attachment.filename, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } +} + @Composable private fun ReplyButton( enabled: Boolean = true, From 49082a2e612955c3a56aa4e4378c3ee5e532026f Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 29 Oct 2025 12:21:06 +0100 Subject: [PATCH 103/153] Downloading attachments --- .../he/ui/HEConversationDetailScreen.kt | 25 +++++++++++-------- .../support/he/ui/HESupportActivity.kt | 13 ++++++++++ .../ui/reader/ReaderFileDownloadManager.kt | 7 +++++- WordPress/src/main/res/values/strings.xml | 1 + 4 files changed, 35 insertions(+), 11 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 834dd0ee4c5d..975fa2a0a388 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 @@ -79,7 +79,8 @@ fun HEConversationDetailScreen( onClearMessageSendResult: () -> Unit = {}, onAddImageClick: () -> Unit = {}, selectedImagePaths: List = emptyList(), - onRemoveImage: (String) -> Unit = {} + onRemoveImage: (String) -> Unit = {}, + onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -146,7 +147,8 @@ fun HEConversationDetailScreen( ) { message -> MessageItem( message = message, - timestamp = formatRelativeTime(message.createdAt, resources) + timestamp = formatRelativeTime(message.createdAt, resources), + onDownloadAttachment = onDownloadAttachment ) } @@ -268,7 +270,8 @@ private fun ConversationTitleCard(title: String) { @Composable private fun MessageItem( message: SupportMessage, - timestamp: String + timestamp: String, + onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -325,25 +328,27 @@ private fun MessageItem( // Display attachments if present if (message.attachments.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) - AttachmentsList(attachments = message.attachments) + AttachmentsList( + attachments = message.attachments, + onDownloadAttachment = onDownloadAttachment + ) } } } } @Composable -private fun AttachmentsList(attachments: List) { - val context = LocalContext.current - +private fun AttachmentsList( + attachments: List, + onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit +) { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { attachments.forEach { attachment -> AttachmentItem( attachment = attachment, - onClick = { - // TODO: open the attachment - } + onClick = { onDownloadAttachment(attachment) } ) } } 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 26a67031ab73..fde84b5635ff 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 @@ -34,6 +34,7 @@ import org.wordpress.android.R import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.ui.photopicker.MediaPickerLauncher import org.wordpress.android.ui.photopicker.MediaPickerConstants +import org.wordpress.android.ui.reader.ReaderFileDownloadManager import org.wordpress.android.ui.RequestCodes import org.wordpress.android.ui.media.MediaBrowserType import javax.inject.Inject @@ -41,6 +42,7 @@ import javax.inject.Inject @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { @Inject lateinit var mediaPickerLauncher: MediaPickerLauncher + @Inject lateinit var fileDownloadManager: ReaderFileDownloadManager private val viewModel by viewModels() private lateinit var composeView: ComposeView @@ -252,6 +254,17 @@ class HESupportActivity : AppCompatActivity() { selectedDetailImageUris = selectedDetailImageUris.filterIndexed { i, _ -> i != index } selectedDetailImagePaths = selectedDetailImagePaths.filterIndexed { i, _ -> i != index } } + }, + onDownloadAttachment = { attachment -> + // Show loading snackbar + scope.launch { + snackbarHostState.showSnackbar( + message = getString(R.string.he_support_downloading_attachment, attachment.filename), + duration = SnackbarDuration.Short + ) + } + // Start download with proper filename + fileDownloadManager.downloadFile(attachment.url, attachment.filename) } ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFileDownloadManager.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFileDownloadManager.kt index e6a151474a4e..810d3dc9523a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFileDownloadManager.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFileDownloadManager.kt @@ -25,13 +25,18 @@ class ReaderFileDownloadManager @Suppress("DEPRECATION") fun downloadFile(fileUrl: String) { + val fileName = downloadManager.guessFileName(fileUrl) + downloadFile(fileUrl, fileName) + } + + @Suppress("DEPRECATION") + fun downloadFile(fileUrl: String, fileName: String) { val request = downloadManager.buildRequest(fileUrl) for (entry in authenticationUtils.getAuthHeaders(fileUrl).entries) { request.addRequestHeader(entry.key, entry.value) } - val fileName = downloadManager.guessFileName(fileUrl) request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) request.setMimeType(downloadManager.getMimeType(fileUrl)) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 1a8c9ca316f2..af6f0a628d98 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5166,6 +5166,7 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Reply Send Message + Downloading %1$s… Screenshots (Optional) Adding screenshots can help us understand and resolve your issue faster. Add Screenshots From 6e93ffdd0dba2adc10b3e4b7abc9d59700132074 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 29 Oct 2025 12:27:48 +0100 Subject: [PATCH 104/153] detekt --- .../support/he/model/SupportMessage.kt | 1 - .../he/ui/HEConversationDetailScreen.kt | 3 --- .../support/he/ui/HESupportActivity.kt | 22 ++++++++++++++----- 3 files changed, 17 insertions(+), 9 deletions(-) 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 969d121c5b35..4b6b18145bcb 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 @@ -2,7 +2,6 @@ package org.wordpress.android.support.he.model import androidx.compose.runtime.Immutable import androidx.compose.ui.text.AnnotatedString -import uniffi.wp_api.AttachmentMetadataValue import java.util.Date @Immutable 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 975fa2a0a388..628b9c303749 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 @@ -1,8 +1,6 @@ package org.wordpress.android.support.he.ui -import android.content.Intent import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.core.net.toUri import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -47,7 +45,6 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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 fde84b5635ff..fbf81f66fc47 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 @@ -31,18 +31,21 @@ 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.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.ui.photopicker.MediaPickerLauncher import org.wordpress.android.ui.photopicker.MediaPickerConstants import org.wordpress.android.ui.reader.ReaderFileDownloadManager import org.wordpress.android.ui.RequestCodes import org.wordpress.android.ui.media.MediaBrowserType +import org.wordpress.android.util.AppLog import javax.inject.Inject @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { @Inject lateinit var mediaPickerLauncher: MediaPickerLauncher @Inject lateinit var fileDownloadManager: ReaderFileDownloadManager + @Inject lateinit var appLogWrapper: AppLogWrapper private val viewModel by viewModels() private lateinit var composeView: ComposeView @@ -138,6 +141,7 @@ class HESupportActivity : AppCompatActivity() { } } + @Suppress("TooGenericExceptionCaught") private fun copyUriToTempFile(uri: Uri): java.io.File? { return try { val inputStream = contentResolver.openInputStream(uri) ?: return null @@ -151,6 +155,7 @@ class HESupportActivity : AppCompatActivity() { tempFile } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URI to temp file: ${e.stackTraceToString()}") null } } @@ -251,15 +256,20 @@ class HESupportActivity : AppCompatActivity() { onRemoveImage = { uriString -> val index = selectedDetailImageUris.indexOfFirst { it.toString() == uriString } if (index >= 0) { - selectedDetailImageUris = selectedDetailImageUris.filterIndexed { i, _ -> i != index } - selectedDetailImagePaths = selectedDetailImagePaths.filterIndexed { i, _ -> i != index } + selectedDetailImageUris = + selectedDetailImageUris.filterIndexed { i, _ -> i != index } + selectedDetailImagePaths = + selectedDetailImagePaths.filterIndexed { i, _ -> i != index } } }, onDownloadAttachment = { attachment -> // Show loading snackbar scope.launch { snackbarHostState.showSnackbar( - message = getString(R.string.he_support_downloading_attachment, attachment.filename), + message = getString( + R.string.he_support_downloading_attachment, + attachment.filename + ), duration = SnackbarDuration.Short ) } @@ -303,8 +313,10 @@ class HESupportActivity : AppCompatActivity() { onRemoveImage = { uriString -> val index = selectedNewTicketImageUris.indexOfFirst { it.toString() == uriString } if (index >= 0) { - selectedNewTicketImageUris = selectedNewTicketImageUris.filterIndexed { i, _ -> i != index } - selectedNewTicketImagePaths = selectedNewTicketImagePaths.filterIndexed { i, _ -> i != index } + selectedNewTicketImageUris = + selectedNewTicketImageUris.filterIndexed { i, _ -> i != index } + selectedNewTicketImagePaths = + selectedNewTicketImagePaths.filterIndexed { i, _ -> i != index } } }, ) From 1c6300d0db2eeeed5a037f93d2f29ca4d6a10b59 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:28:34 +0100 Subject: [PATCH 105/153] Support pagination --- .../repository/AIBotSupportRepository.kt | 12 ++- .../aibot/ui/AIBotConversationDetailScreen.kt | 88 +++++++++++++++---- .../support/aibot/ui/AIBotSupportActivity.kt | 7 ++ .../support/aibot/ui/AIBotSupportViewModel.kt | 61 ++++++++++++- 4 files changed, 145 insertions(+), 23 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 98578659236b..e555fcb027b9 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 @@ -15,6 +15,7 @@ import uniffi.wp_api.AddMessageToBotConversationParams import uniffi.wp_api.BotConversationSummary import uniffi.wp_api.CreateBotConversationParams import uniffi.wp_api.GetBotConversationParams +import java.util.Date import javax.inject.Inject import javax.inject.Named @@ -67,12 +68,15 @@ class AIBotSupportRepository @Inject constructor( } } - suspend fun loadConversation(chatId: Long): BotConversation? = withContext(ioDispatcher) { + suspend fun loadConversation(chatId: Long, pageNumber: Long = 1L): BotConversation? = withContext(ioDispatcher) { val response = wpComApiClient.request { requestBuilder -> requestBuilder.supportBots().getBotConversation( botId = BOT_ID, chatId = chatId.toULong(), - params = GetBotConversationParams() + params = GetBotConversationParams( + pageNumber = pageNumber.toULong(), + itemsPerPage = 4U + ) ) } when (response) { @@ -158,8 +162,8 @@ class AIBotSupportRepository @Inject constructor( BotConversation ( id = chatId.toLong(), createdAt = createdAt, - mostRecentMessageDate = messages.last().createdAt, - lastMessage = messages.last().content, + mostRecentMessageDate = messages.lastOrNull()?.createdAt ?: Date(), + lastMessage = messages.lastOrNull()?.content.orEmpty(), messages = messages.map { it.toBotMessage() } ) 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 5b3d1b494ba9..d7d2fb7c3720 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 @@ -39,8 +39,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources @@ -65,26 +65,49 @@ fun AIBotConversationDetailScreen( conversation: BotConversation, isLoading: Boolean, isBotTyping: Boolean, + isLoadingOlderMessages: Boolean, + hasMorePages: Boolean, canSendMessage: Boolean, userName: String, onBackClick: () -> Unit, - onSendMessage: (String) -> Unit + onSendMessage: (String) -> Unit, + onLoadOlderMessages: () -> Unit ) { var messageText by remember { mutableStateOf("") } val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() - - // Scroll to bottom when conversation changes or messages are added or typing state changes - LaunchedEffect(conversation.id, conversation.messages.size, isBotTyping) { - if (conversation.messages.isNotEmpty() || isBotTyping) { - coroutineScope.launch { - // +2 for welcome header and spacer, +1 if typing indicator is showing - val itemCount = conversation.messages.size + 2 + if (isBotTyping) 1 else 0 - listState.animateScrollToItem(itemCount) - } + + // Scroll to bottom when new messages are added at the end (not when loading older messages at the beginning) + // Only scroll to bottom when: + // 1. The last message changes (new message added at the end) + // 2. Bot starts typing + // 3. We're not loading older messages (which adds messages at the beginning) + LaunchedEffect(conversation.id, conversation.messages.lastOrNull()?.id, isBotTyping) { + if ((conversation.messages.isNotEmpty() || isBotTyping) && !isLoadingOlderMessages) { + listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) } } + // Detect when user scrolls to the top to load older messages + LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) { + snapshotFlow { listState.firstVisibleItemIndex } + .collect { firstVisibleIndex -> + // Trigger pagination when user scrolls to the top + // The top threshold depends on whether we're currently showing a loading indicator + val shouldLoadMore = if (isLoadingOlderMessages) { + // If loading indicator is shown at position 0, we shouldn't trigger again + false + } else { + // Check if we're at the very top (index 0) + // Note: when hasMorePages=true, there's no welcome header, so index 0 is the first message + firstVisibleIndex == 0 + } + + if (shouldLoadMore && !isLoading && hasMorePages) { + onLoadOlderMessages() + } + } + } + val resources = LocalResources.current Scaffold( @@ -128,8 +151,25 @@ fun AIBotConversationDetailScreen( state = listState, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - WelcomeHeader(userName) + // Show loading indicator at top when loading older messages + if (isLoadingOlderMessages) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + + // Only show welcome header when we're at the beginning (no more pages to load) + if (!hasMorePages) { + item { + WelcomeHeader(userName) + } } // Key ensures the items recompose when messages change @@ -369,9 +409,12 @@ private fun ConversationDetailScreenPreview() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -389,9 +432,12 @@ private fun ConversationDetailScreenPreviewDark() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -409,9 +455,12 @@ private fun ConversationDetailScreenWordPressPreview() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } @@ -429,9 +478,12 @@ private fun ConversationDetailScreenPreviewWordPressDark() { conversation = sampleConversation, isLoading = false, isBotTyping = false, + isLoadingOlderMessages = false, + hasMorePages = false, canSendMessage = true, onBackClick = { }, - onSendMessage = { } + onSendMessage = { }, + onLoadOlderMessages = { } ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt index 79f2bde12052..6d6123dc53ba 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 @@ -131,6 +131,8 @@ class AIBotSupportActivity : AppCompatActivity() { val selectedConversation by viewModel.selectedConversation.collectAsState() val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isBotTyping by viewModel.isBotTyping.collectAsState() + val isLoadingOlderMessages by viewModel.isLoadingOlderMessages.collectAsState() + val hasMorePages by viewModel.hasMorePages.collectAsState() val canSendMessage by viewModel.canSendMessage.collectAsState() val userInfo by viewModel.userInfo.collectAsState() selectedConversation?.let { conversation -> @@ -140,10 +142,15 @@ class AIBotSupportActivity : AppCompatActivity() { conversation = conversation, isLoading = isLoadingConversation, isBotTyping = isBotTyping, + isLoadingOlderMessages = isLoadingOlderMessages, + hasMorePages = hasMorePages, canSendMessage = canSendMessage, onBackClick = { viewModel.onBackClick() }, onSendMessage = { text -> viewModel.sendMessage(text) + }, + onLoadOlderMessages = { + viewModel.loadOlderMessages() } ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt index 7f750e574930..3631fb318dfd 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,6 +29,14 @@ class AIBotSupportViewModel @Inject constructor( private val _isBotTyping = MutableStateFlow(false) val isBotTyping: StateFlow = _isBotTyping.asStateFlow() + private val _isLoadingOlderMessages = MutableStateFlow(false) + val isLoadingOlderMessages: StateFlow = _isLoadingOlderMessages.asStateFlow() + + private val _hasMorePages = MutableStateFlow(true) + val hasMorePages: StateFlow = _hasMorePages.asStateFlow() + + private var currentPage = 1L + override fun initRepository(accessToken: String) { aiBotSupportRepository.init(accessToken, accountStore.account.userId) } @@ -37,8 +45,12 @@ class AIBotSupportViewModel @Inject constructor( override suspend fun getConversation(conversationId: Long): BotConversation? { _canSendMessage.value = false - return aiBotSupportRepository.loadConversation(conversationId).also { + currentPage = 1L + _hasMorePages.value = true + return aiBotSupportRepository.loadConversation(conversationId, pageNumber = currentPage).also { conversation -> _canSendMessage.value = true + // Check if there are more pages (empty messages means end of pagination) + _hasMorePages.value = conversation?.messages?.isNotEmpty() == true } } @@ -53,10 +65,57 @@ class AIBotSupportViewModel @Inject constructor( messages = listOf() ) _canSendMessage.value = true + currentPage = 1L + _hasMorePages.value = false setNewConversation(botConversation) } } + fun loadOlderMessages() { + if (!_hasMorePages.value || _isLoadingOlderMessages.value) { + return + } + + viewModelScope.launch { + try { + _isLoadingOlderMessages.value = true + val conversationId = _selectedConversation.value?.id ?: return@launch + + currentPage++ + val olderMessagesConversation = aiBotSupportRepository.loadConversation( + conversationId, + pageNumber = currentPage + ) + + if (olderMessagesConversation != null) { + val olderMessages = olderMessagesConversation.messages + + // Check if we've reached the end (empty messages) + if (olderMessages.isEmpty()) { + _hasMorePages.value = false + } else { + // Prepend older messages to the existing ones + // (older messages go at the beginning of the list) + val currentMessages = _selectedConversation.value?.messages ?: emptyList() + _selectedConversation.value = _selectedConversation.value?.copy( + messages = olderMessages + currentMessages + ) + } + } else { + // Error loading, stay on current page + currentPage-- + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") + } + } catch (throwable: Throwable) { + currentPage-- + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } finally { + _isLoadingOlderMessages.value = false + } + } + } + @Suppress("TooGenericExceptionCaught") fun sendMessage(message: String) { viewModelScope.launch { From 53ec08946600a19e08e8d12159b08ec38ff49b3b Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:44:28 +0100 Subject: [PATCH 106/153] Triggering in the 4th element --- .../aibot/ui/AIBotConversationDetailScreen.kt | 16 +++++----------- 1 file changed, 5 insertions(+), 11 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 d7d2fb7c3720..875770bd01d1 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 @@ -87,20 +87,14 @@ fun AIBotConversationDetailScreen( } } - // Detect when user scrolls to the top to load older messages + // Detect when user scrolls near the top to load older messages LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) { snapshotFlow { listState.firstVisibleItemIndex } .collect { firstVisibleIndex -> - // Trigger pagination when user scrolls to the top - // The top threshold depends on whether we're currently showing a loading indicator - val shouldLoadMore = if (isLoadingOlderMessages) { - // If loading indicator is shown at position 0, we shouldn't trigger again - false - } else { - // Check if we're at the very top (index 0) - // Note: when hasMorePages=true, there's no welcome header, so index 0 is the first message - firstVisibleIndex == 0 - } + // Trigger pagination when user reaches the 4th message from the top + val threshold = 4 + + val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= threshold if (shouldLoadMore && !isLoading && hasMorePages) { onLoadOlderMessages() From 7370761fd1c1644a538b30e8c07f5157a768b360 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:47:25 +0100 Subject: [PATCH 107/153] detekt --- .../android/support/aibot/ui/AIBotSupportViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) 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 3631fb318dfd..3b1ac7d438fd 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 @@ -71,6 +71,7 @@ class AIBotSupportViewModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") fun loadOlderMessages() { if (!_hasMorePages.value || _isLoadingOlderMessages.value) { return @@ -104,10 +105,12 @@ class AIBotSupportViewModel @Inject constructor( } else { // Error loading, stay on current page currentPage-- + _errorMessage.value = ErrorType.GENERAL appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") } } catch (throwable: Throwable) { currentPage-- + _errorMessage.value = ErrorType.GENERAL appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + "${throwable.message} - ${throwable.stackTraceToString()}") } finally { From 7bd891911317ceba469175c23093e7800ef01627 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 13:49:43 +0100 Subject: [PATCH 108/153] TODO for debug purposes --- .../android/support/aibot/repository/AIBotSupportRepository.kt | 2 ++ 1 file changed, 2 insertions(+) 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 e555fcb027b9..6bd408722719 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 @@ -75,6 +75,8 @@ class AIBotSupportRepository @Inject constructor( chatId = chatId.toULong(), params = GetBotConversationParams( pageNumber = pageNumber.toULong(), + // TODO: this is set to 4 for testing purpose + // The TODO is preventing the Pr to be merged. Change it to a higher number before that itemsPerPage = 4U ) ) From 9016a82be972daf50dc86436977a49a382ef25bb Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 27 Oct 2025 14:06:25 +0100 Subject: [PATCH 109/153] Claude PR suggestions Mutex and constant --- .../aibot/ui/AIBotConversationDetailScreen.kt | 7 +- .../support/aibot/ui/AIBotSupportViewModel.kt | 71 +++++++++++-------- 2 files changed, 43 insertions(+), 35 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 875770bd01d1..825102108dd8 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 @@ -58,6 +58,8 @@ import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.aibot.util.generateSampleBotConversations import org.wordpress.android.ui.compose.theme.AppThemeM3 +private const val PAGINATION_TRIGGER_THRESHOLD = 4 + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AIBotConversationDetailScreen( @@ -91,10 +93,7 @@ fun AIBotConversationDetailScreen( LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) { snapshotFlow { listState.firstVisibleItemIndex } .collect { firstVisibleIndex -> - // Trigger pagination when user reaches the 4th message from the top - val threshold = 4 - - val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= threshold + val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= PAGINATION_TRIGGER_THRESHOLD if (shouldLoadMore && !isLoading && hasMorePages) { onLoadOlderMessages() 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 3b1ac7d438fd..3faa487c904a 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.aibot.model.BotConversation @@ -35,6 +37,7 @@ class AIBotSupportViewModel @Inject constructor( private val _hasMorePages = MutableStateFlow(true) val hasMorePages: StateFlow = _hasMorePages.asStateFlow() + private val paginationMutex = Mutex() private var currentPage = 1L override fun initRepository(accessToken: String) { @@ -49,8 +52,6 @@ class AIBotSupportViewModel @Inject constructor( _hasMorePages.value = true return aiBotSupportRepository.loadConversation(conversationId, pageNumber = currentPage).also { conversation -> _canSendMessage.value = true - // Check if there are more pages (empty messages means end of pagination) - _hasMorePages.value = conversation?.messages?.isNotEmpty() == true } } @@ -78,43 +79,51 @@ class AIBotSupportViewModel @Inject constructor( } viewModelScope.launch { - try { - _isLoadingOlderMessages.value = true - val conversationId = _selectedConversation.value?.id ?: return@launch + // Use mutex to prevent concurrent pagination requests + paginationMutex.withLock { + // Double-check conditions after acquiring lock + if (!_hasMorePages.value || _isLoadingOlderMessages.value) { + return@launch + } - currentPage++ - val olderMessagesConversation = aiBotSupportRepository.loadConversation( - conversationId, - pageNumber = currentPage - ) + try { + _isLoadingOlderMessages.value = true + val conversationId = _selectedConversation.value?.id ?: return@withLock - if (olderMessagesConversation != null) { - val olderMessages = olderMessagesConversation.messages + currentPage++ + val olderMessagesConversation = aiBotSupportRepository.loadConversation( + conversationId, + pageNumber = currentPage + ) - // Check if we've reached the end (empty messages) - if (olderMessages.isEmpty()) { - _hasMorePages.value = false + if (olderMessagesConversation != null) { + val olderMessages = olderMessagesConversation.messages + + // Check if we've reached the end (empty messages) + if (olderMessages.isEmpty()) { + _hasMorePages.value = false + } else { + // Prepend older messages to the existing ones + // (older messages go at the beginning of the list) + val currentMessages = _selectedConversation.value?.messages ?: emptyList() + _selectedConversation.value = _selectedConversation.value?.copy( + messages = olderMessages + currentMessages + ) + } } else { - // Prepend older messages to the existing ones - // (older messages go at the beginning of the list) - val currentMessages = _selectedConversation.value?.messages ?: emptyList() - _selectedConversation.value = _selectedConversation.value?.copy( - messages = olderMessages + currentMessages - ) + // Error loading, stay on current page + currentPage-- + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") } - } else { - // Error loading, stay on current page + } catch (throwable: Throwable) { currentPage-- _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: response is null") + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } finally { + _isLoadingOlderMessages.value = false } - } catch (throwable: Throwable) { - currentPage-- - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error loading older messages: " + - "${throwable.message} - ${throwable.stackTraceToString()}") - } finally { - _isLoadingOlderMessages.value = false } } } From 88fc5d2b5e7ba51013c0f0bccb7c2bc494ac1183 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 29 Oct 2025 12:43:34 +0100 Subject: [PATCH 110/153] Detekt --- .../android/support/aibot/ui/AIBotConversationDetailScreen.kt | 1 - 1 file changed, 1 deletion(-) 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 825102108dd8..4f2d10fea081 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 @@ -50,7 +50,6 @@ 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 kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage From d8affd984ca8b1fc9d4d26bd483fdebc9d1e87aa Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 29 Oct 2025 17:12:44 +0100 Subject: [PATCH 111/153] Removing testing code --- .../support/aibot/repository/AIBotSupportRepository.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 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 6bd408722719..86b37527a7ac 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt @@ -20,6 +20,7 @@ import javax.inject.Inject import javax.inject.Named private const val BOT_ID = "jetpack-chat-mobile" +private const val ITEMS_PER_PAGE = 20 class AIBotSupportRepository @Inject constructor( private val appLogWrapper: AppLogWrapper, @@ -75,9 +76,7 @@ class AIBotSupportRepository @Inject constructor( chatId = chatId.toULong(), params = GetBotConversationParams( pageNumber = pageNumber.toULong(), - // TODO: this is set to 4 for testing purpose - // The TODO is preventing the Pr to be merged. Change it to a higher number before that - itemsPerPage = 4U + itemsPerPage = ITEMS_PER_PAGE.toULong() ) ) } From 260ded1c02a9b3cc6d7c6b7ea53584c641ae8227 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 30 Oct 2025 09:39:14 +0100 Subject: [PATCH 112/153] Updating RS library version --- 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 8b4e9bdd9ee1..60ed54dac378 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-59989859de3621982636bc413142d86f884f5837' +wordpress-rs = 'trunk-d1f81fe81484cf49259eeb7da671995971a73cf2' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1' From 109cc4134ecba0a7b1e31fb4ace9d69df26628d5 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 30 Oct 2025 11:28:59 +0100 Subject: [PATCH 113/153] Opening images in fullscreen --- .../he/ui/HEConversationDetailScreen.kt | 166 +++++++++++++++++- 1 file changed, 165 insertions(+), 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 628b9c303749..7460d98fd9bb 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 @@ -1,8 +1,10 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,21 +25,25 @@ 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.material.icons.filled.Close import androidx.compose.material3.Button 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.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface 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.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -45,6 +51,11 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -54,6 +65,10 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import coil.request.ImageRequest import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.he.model.SupportConversation @@ -89,6 +104,9 @@ fun HEConversationDetailScreen( var draftMessageText by remember { mutableStateOf("") } var draftIncludeAppLogs by remember { mutableStateOf(false) } + // State for fullscreen image preview + var previewImageUrl by remember { mutableStateOf(null) } + // Scroll to bottom when conversation changes or new messages arrive LaunchedEffect(conversation.messages.size) { if (conversation.messages.isNotEmpty()) { @@ -145,6 +163,7 @@ fun HEConversationDetailScreen( MessageItem( message = message, timestamp = formatRelativeTime(message.createdAt, resources), + onPreviewImage = { imageUrl -> previewImageUrl = imageUrl }, onDownloadAttachment = onDownloadAttachment ) } @@ -193,6 +212,22 @@ fun HEConversationDetailScreen( onRemoveImage = onRemoveImage ) } + + // Show fullscreen image preview when an image attachment is tapped + previewImageUrl?.let { imageUrl -> + // Find the attachment with this URL to get the filename for download + val attachment = conversation.messages + .flatMap { it.attachments } + .firstOrNull { it.url == imageUrl } + + FullscreenImagePreview( + imageUrl = imageUrl, + onDismiss = { previewImageUrl = null }, + onDownload = { + attachment?.let { onDownloadAttachment(it) } + } + ) + } } @Composable @@ -268,6 +303,7 @@ private fun ConversationTitleCard(title: String) { private fun MessageItem( message: SupportMessage, timestamp: String, + onPreviewImage: (String) -> Unit, onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -327,6 +363,7 @@ private fun MessageItem( Spacer(modifier = Modifier.height(12.dp)) AttachmentsList( attachments = message.attachments, + onPreviewImage = onPreviewImage, onDownloadAttachment = onDownloadAttachment ) } @@ -337,6 +374,7 @@ private fun MessageItem( @Composable private fun AttachmentsList( attachments: List, + onPreviewImage: (String) -> Unit, onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit ) { Column( @@ -345,7 +383,13 @@ private fun AttachmentsList( attachments.forEach { attachment -> AttachmentItem( attachment = attachment, - onClick = { onDownloadAttachment(attachment) } + onClick = { + if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { + onPreviewImage(attachment.url) + } else { + onDownloadAttachment(attachment) + } + } ) } } @@ -533,6 +577,126 @@ private fun ReplyBottomSheet( } } +@Composable +private fun FullscreenImagePreview( + imageUrl: String, + onDismiss: () -> Unit, + onDownload: () -> Unit = {} +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onDismiss), + color = Color.Black + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + // Zoomable image + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } + } + }, + contentScale = ContentScale.Fit + ) + + // Top bar with close button + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Download button + IconButton( + onClick = { + onDownload.invoke() + onDismiss.invoke() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_get_app_white_24dp), + contentDescription = "Download image", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + + // Close button + IconButton( + onClick = onDismiss + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.close), + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + } + } + } +} + +@Preview(showBackground = true, name = "Fullscreen Image Preview") +@Composable +private fun FullscreenImagePreviewPreview() { + AppThemeM3(isDarkTheme = false) { + FullscreenImagePreview( + imageUrl = "https://via.placeholder.com/800x600", + onDismiss = { }, + onDownload = { } + ) + } +} + +@Preview(showBackground = true, name = "Fullscreen Image Preview - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun FullscreenImagePreviewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + FullscreenImagePreview( + imageUrl = "https://via.placeholder.com/800x600", + onDismiss = { }, + onDownload = { } + ) + } +} + @Preview(showBackground = true, name = "HE Conversation Detail") @Composable private fun HEConversationDetailScreenPreview() { From 0d782f0df7208c439f7396b87028d26b86b595b8 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 30 Oct 2025 11:39:41 +0100 Subject: [PATCH 114/153] Improving full screen image UX --- .../he/ui/HEConversationDetailScreen.kt | 87 ++++++++++++------- WordPress/src/main/res/values/strings.xml | 1 + 2 files changed, 55 insertions(+), 33 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 7460d98fd9bb..69a1dcce79ef 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 @@ -48,6 +48,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import coil.compose.SubcomposeAsyncImage import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,7 +68,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage import coil.request.ImageRequest import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime @@ -604,42 +604,63 @@ private fun FullscreenImagePreview( Box( modifier = Modifier.fillMaxSize() ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) // Zoomable image - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY - ) - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(1f, 5f) - if (scale > 1f) { - offsetX += pan.x - offsetY += pan.y - } else { - offsetX = 0f - offsetY = 0f + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } } - } - }, - contentScale = ContentScale.Fit - ) + }, + contentScale = ContentScale.Fit, + error = { + Icon( + painter = painterResource(R.drawable.ic_image_white_24dp), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + } + ) + } // Top bar with close button Row( modifier = Modifier .align(Alignment.TopEnd) - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(24.dp) + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { // Download button IconButton( @@ -650,9 +671,9 @@ private fun FullscreenImagePreview( ) { Icon( painter = painterResource(R.drawable.ic_get_app_white_24dp), - contentDescription = "Download image", + contentDescription = stringResource(R.string.he_support_download_image), tint = Color.White, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(24.dp) ) } @@ -664,7 +685,7 @@ private fun FullscreenImagePreview( imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.close), tint = Color.White, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(24.dp) ) } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index af6f0a628d98..25c34f46afbf 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5173,6 +5173,7 @@ 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. + Download image Contact Support From f15af115a4423dd4e139bc56d0b8ed7a6e49a083 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 30 Oct 2025 11:51:07 +0100 Subject: [PATCH 115/153] Improving semantics --- .../support/he/ui/HEConversationDetailScreen.kt | 15 ++++++++++++--- WordPress/src/main/res/values/strings.xml | 3 +++ 2 files changed, 15 insertions(+), 3 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 833025a95a41..35dd9b8749d1 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 @@ -588,6 +588,11 @@ private fun FullscreenImagePreview( var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } + // Load semantics + val loadingImageDescription = stringResource(R.string.he_support_loading_image) + val attachmentImageDescription = stringResource(R.string.he_support_attachment_image) + val failedToLoadImageDescription = stringResource(R.string.he_support_failed_to_load_image) + Dialog( onDismissRequest = onDismiss, properties = DialogProperties( @@ -606,7 +611,11 @@ private fun FullscreenImagePreview( modifier = Modifier.fillMaxSize() ) { CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) + .semantics { + contentDescription = loadingImageDescription + } ) // Zoomable image Box( @@ -618,7 +627,7 @@ private fun FullscreenImagePreview( .data(imageUrl) .crossfade(true) .build(), - contentDescription = null, + contentDescription = attachmentImageDescription, modifier = Modifier .fillMaxSize() .graphicsLayer( @@ -643,7 +652,7 @@ private fun FullscreenImagePreview( error = { Icon( painter = painterResource(R.drawable.ic_image_white_24dp), - contentDescription = null, + contentDescription = failedToLoadImageDescription, tint = Color.White, modifier = Modifier.size(48.dp) ) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 25c34f46afbf..5ee2e3365f1b 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5167,6 +5167,9 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Send Message Downloading %1$s… + Loading image + Attachment image + Failed to load image Screenshots (Optional) Adding screenshots can help us understand and resolve your issue faster. Add Screenshots From 81d6d287cfa573151719831ce0ea41da21728e8b Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 30 Oct 2025 17:56:33 +0100 Subject: [PATCH 116/153] Extracting strings --- .../android/support/he/ui/TicketMainContentView.kt | 7 ++----- WordPress/src/main/res/values/strings.xml | 2 ++ 2 files changed, 4 insertions(+), 5 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 e3d103175fb8..153682073331 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 @@ -124,9 +124,6 @@ fun TicketMainContentView( } } - val maxImagesReached = selectedImagePaths.size >= 4 - - val addScreenshotsLabel = stringResource(R.string.he_support_add_screenshots_button) OutlinedButton( onClick = onAddImageClick, @@ -236,7 +233,7 @@ private fun ImagePreviewItem( ) { AsyncImage( model = imagePath, - contentDescription = "Screenshot preview", + contentDescription = stringResource(R.string.he_support_screenshot_preview), modifier = Modifier .size(100.dp) .clip(RoundedCornerShape(12.dp)), @@ -264,7 +261,7 @@ private fun ImagePreviewItem( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Remove screenshot", + contentDescription = stringResource(R.string.he_support_remove_screenshot), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(18.dp) ) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 6902d6c7d18d..115239420418 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5173,6 +5173,8 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Screenshots (Optional) Adding screenshots can help us understand and resolve your issue faster. Add Screenshots + Screenshot preview + Remove screenshot Application Logs (Optional) Include application logs Including logs can help our team investigate issues. Logs may contain recent app activity. From 5c31d48df4af4118df5d6c9cdd1cbb3dc3d231fb Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 10:19:41 +0100 Subject: [PATCH 117/153] Using rs PR fix --- gradle/libs.versions.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 139a3963b30c..5cd9309fd9f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,8 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-d1f81fe81484cf49259eeb7da671995971a73cf2' +## TODO: Use trunk commit when merge +wordpress-rs = '995-5b34ff29422a4db847044c72bd06672be922af22' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1' From 0f67996ad4bdcd800b0dfe0d8842d42b8077a3fa Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 11:55:38 +0100 Subject: [PATCH 118/153] Showing attachment preview --- .../he/ui/HEConversationDetailScreen.kt | 72 ++++++++++++------- 1 file changed, 48 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 b73496fa77a5..c0623fa632b6 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 @@ -15,7 +15,7 @@ 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.layout.width +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -378,7 +378,8 @@ private fun AttachmentsList( onPreviewImage: (String) -> Unit, onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit ) { - Column( + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { attachments.forEach { attachment -> @@ -407,33 +408,56 @@ private fun AttachmentItem( org.wordpress.android.support.he.model.AttachmentType.Other -> R.drawable.ic_pages_white_24dp } - Row( + Box( modifier = Modifier - .fillMaxWidth() + .size(120.dp) .clickable(onClick = onClick) .background( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - shape = RoundedCornerShape(4.dp) - ) - .padding(8.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center ) { - Icon( - painter = painterResource(iconRes), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = attachment.filename, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f) - ) + if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { + // Show image preview for image attachments + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(attachment.url) + .crossfade(true) + .build(), + contentDescription = attachment.filename, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + loading = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + }, + error = { + // Show icon if image fails to load + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + } + ) + } else { + // Show icon for non-image attachments + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + } } } From 3343a032d66bf999494c15ee1424654dbc37bb9a Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 12:34:39 +0100 Subject: [PATCH 119/153] Clearing attachments on new ticket screen close --- .../wordpress/android/support/he/ui/HESupportActivity.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 fbf81f66fc47..986a531a706a 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 @@ -284,6 +284,14 @@ class HESupportActivity : AppCompatActivity() { val userInfo by viewModel.userInfo.collectAsState() val isSendingNewConversation by viewModel.isSendingMessage.collectAsState() + // Clear attachments when opening a new ticket + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { + selectedNewTicketImageUris = emptyList() + selectedNewTicketImagePaths = emptyList() + } + } + HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { viewModel.onBackClick() }, From f3e40c95c38fd8a6e2a1f9d4512ad16568b764bd Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 12:50:27 +0100 Subject: [PATCH 120/153] Removing selected images limit --- .../support/he/ui/HESupportActivity.kt | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 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 986a531a706a..705b4ed772be 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 @@ -243,14 +243,12 @@ class HESupportActivity : AppCompatActivity() { }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, onAddImageClick = { - if (selectedDetailImageUris.size < 4) { - mediaPickerLauncher.showPhotoPickerForResult( - activity = this@HESupportActivity, - browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, - site = null, - localPostId = null - ) - } + mediaPickerLauncher.showPhotoPickerForResult( + activity = this@HESupportActivity, + browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, + site = null, + localPostId = null + ) }, selectedImagePaths = selectedDetailImageUris.map { it.toString() }, onRemoveImage = { uriString -> @@ -308,14 +306,12 @@ class HESupportActivity : AppCompatActivity() { userAvatarUrl = userInfo.avatarUrl, isSendingNewConversation = isSendingNewConversation, onAddImageClick = { - if (selectedNewTicketImageUris.size < 4) { - mediaPickerLauncher.showPhotoPickerForResult( - activity = this@HESupportActivity, - browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, - site = null, - localPostId = null - ) - } + mediaPickerLauncher.showPhotoPickerForResult( + activity = this@HESupportActivity, + browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, + site = null, + localPostId = null + ) }, selectedImagePaths = selectedNewTicketImageUris.map { it.toString() }, onRemoveImage = { uriString -> From ad1382aaf6f4c51948aad02b2ae87d9f0f8c4ac5 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 13:26:21 +0100 Subject: [PATCH 121/153] Unifying attachments handling inside the VM --- .../android/support/he/model/AttachmentUI.kt | 8 ++ .../he/ui/HEConversationDetailScreen.kt | 25 +++--- .../support/he/ui/HENewTicketScreen.kt | 18 ++-- .../support/he/ui/HESupportActivity.kt | 83 ++++++------------- .../support/he/ui/HESupportViewModel.kt | 30 +++++-- .../support/he/ui/TicketMainContentView.kt | 17 ++-- 6 files changed, 88 insertions(+), 93 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt new file mode 100644 index 000000000000..d509ace90d68 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.support.he.model + +import android.net.Uri + +data class AttachmentUI( + val uri: Uri, + val path: String +) 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 c0623fa632b6..8dfbc30b5dee 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 @@ -1,6 +1,7 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTransformGestures @@ -87,11 +88,11 @@ fun HEConversationDetailScreen( isSendingMessage: Boolean = false, messageSendResult: HESupportViewModel.MessageSendResult? = null, onBackClick: () -> Unit, - onSendMessage: (message: String, includeAppLogs: Boolean, attachments: List) -> Unit, + onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit, onClearMessageSendResult: () -> Unit = {}, onAddImageClick: () -> Unit = {}, - selectedImagePaths: List = emptyList(), - onRemoveImage: (String) -> Unit = {}, + selectedImages: List = emptyList(), + onRemoveImage: (Uri) -> Unit = {}, onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} ) { val listState = rememberLazyListState() @@ -199,7 +200,7 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> - onSendMessage(message, includeAppLogs, selectedImagePaths) + onSendMessage(message, includeAppLogs) }, onMessageSentSuccessfully = { // Clear draft after successful send @@ -208,7 +209,7 @@ fun HEConversationDetailScreen( onClearMessageSendResult() }, onAddImageClick = onAddImageClick, - selectedImagePaths = selectedImagePaths, + selectedImages = selectedImages, onRemoveImage = onRemoveImage ) } @@ -508,8 +509,8 @@ private fun ReplyBottomSheet( onSend: (String, Boolean) -> Unit, onMessageSentSuccessfully: () -> Unit, onAddImageClick: () -> Unit = {}, - selectedImagePaths: List = emptyList(), - onRemoveImage: (String) -> Unit = {} + selectedImages: List = emptyList(), + onRemoveImage: (Uri) -> Unit = {} ) { var messageText by remember { mutableStateOf(initialMessageText) } var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } @@ -594,7 +595,7 @@ private fun ReplyBottomSheet( onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, enabled = !isSending, - selectedImagePaths = selectedImagePaths, + selectedImages = selectedImages, onAddImageClick = onAddImageClick, onRemoveImage = onRemoveImage ) @@ -763,7 +764,7 @@ private fun HEConversationDetailScreenPreview() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _, _ -> } + onSendMessage = { _, _ -> } ) } } @@ -779,7 +780,7 @@ private fun HEConversationDetailScreenPreviewDark() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _, _ -> } + onSendMessage = { _, _ -> } ) } } @@ -795,7 +796,7 @@ private fun HEConversationDetailScreenWordPressPreview() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _, _ -> } + onSendMessage = { _, _ -> } ) } } @@ -812,7 +813,7 @@ private fun HEConversationDetailScreenPreviewWordPressDark() { isLoading = true, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _, _ -> } + onSendMessage = { _, _ -> } ) } } 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 16a1aea15031..94682d50df20 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 @@ -1,6 +1,7 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -68,15 +69,14 @@ fun HENewTicketScreen( subject: String, messageText: String, siteAddress: String, - attachments: List ) -> Unit, userName: String = "", userEmail: String = "", userAvatarUrl: String? = null, isSendingNewConversation: Boolean = false, onAddImageClick: () -> Unit = { }, - selectedImagePaths: List = emptyList(), - onRemoveImage: (String) -> Unit = { } + selectedImages: List = emptyList(), + onRemoveImage: (Uri) -> Unit = { } ) { var selectedCategory by remember { mutableStateOf(null) } var subject by remember { mutableStateOf("") } @@ -99,7 +99,7 @@ fun HENewTicketScreen( isLoading = isSendingNewConversation, onClick = { selectedCategory?.let { category -> - onSubmit(category, subject, messageText, siteAddress, selectedImagePaths) + onSubmit(category, subject, messageText, siteAddress) } } ) @@ -198,7 +198,7 @@ fun HENewTicketScreen( includeAppLogs = includeAppLogs, onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, - selectedImagePaths = selectedImagePaths, + selectedImages = selectedImages, onAddImageClick = onAddImageClick, onRemoveImage = onRemoveImage ) @@ -425,7 +425,7 @@ private fun HENewTicketScreenPreview() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _, _ -> }, + onSubmit = { _, _, _, _-> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -441,7 +441,7 @@ private fun HENewTicketScreenPreviewDark() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _, _ -> }, + onSubmit = { _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -457,7 +457,7 @@ private fun HENewTicketScreenWordPressPreview() { HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { }, - onSubmit = { _, _, _, _, _ -> }, + onSubmit = { _, _, _, _ -> }, userName = "Test user", userEmail = "test.user@automattic.com", userAvatarUrl = null @@ -473,7 +473,7 @@ private fun HENewTicketScreenPreviewWordPressDark() { HENewTicketScreen( snackbarHostState = snackbarHostState, 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 705b4ed772be..e56bc837d0ee 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 @@ -51,14 +51,6 @@ class HESupportActivity : AppCompatActivity() { private lateinit var composeView: ComposeView private lateinit var navController: NavHostController - // State for selected images in Detail screen - private var selectedDetailImageUris by mutableStateOf>(emptyList()) - private var selectedDetailImagePaths by mutableStateOf>(emptyList()) - - // State for selected images in NewTicket screen - private var selectedNewTicketImageUris by mutableStateOf>(emptyList()) - private var selectedNewTicketImagePaths by mutableStateOf>(emptyList()) - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) composeView = ComposeView(this) @@ -114,24 +106,10 @@ class HESupportActivity : AppCompatActivity() { // Determine which screen is active by checking current destination val currentDestination = navController.currentDestination?.route - when (currentDestination) { - ConversationScreen.Detail.name -> { - // Convert URIs to file paths - val newPaths = newUris.mapNotNull { uri -> - copyUriToTempFile(uri)?.absolutePath - } - // Update state immutably to trigger recomposition - selectedDetailImageUris = selectedDetailImageUris + newUris - selectedDetailImagePaths = selectedDetailImagePaths + newPaths - } - ConversationScreen.NewTicket.name -> { - // Convert URIs to file paths - val newPaths = newUris.mapNotNull { uri -> - copyUriToTempFile(uri)?.absolutePath - } - // Update state immutably to trigger recomposition - selectedNewTicketImageUris = selectedNewTicketImageUris + newUris - selectedNewTicketImagePaths = selectedNewTicketImagePaths + newPaths + // Convert URIs to file paths and add to ViewModel + newUris.forEach { uri -> + copyUriToTempFile(uri)?.absolutePath?.let { path -> + viewModel.addAttachment(uri, path) } } } @@ -214,18 +192,18 @@ class HESupportActivity : AppCompatActivity() { } composable(route = ConversationScreen.Detail.name) { + // Clear attachments when leaving conversation screen + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { + viewModel.clearAttachments() + } + } + val selectedConversation by viewModel.selectedConversation.collectAsState() val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() val isSendingMessage by viewModel.isSendingMessage.collectAsState() val messageSendResult by viewModel.messageSendResult.collectAsState() - - // Clear images after successful message send - LaunchedEffect(messageSendResult) { - if (messageSendResult is HESupportViewModel.MessageSendResult.Success) { - selectedDetailImageUris = emptyList() - selectedDetailImagePaths = emptyList() - } - } + val attachments by viewModel.attachments.collectAsState() selectedConversation?.let { conversation -> HEConversationDetailScreen( @@ -235,10 +213,9 @@ class HESupportActivity : AppCompatActivity() { isSendingMessage = isSendingMessage, messageSendResult = messageSendResult, onBackClick = { viewModel.onBackClick() }, - onSendMessage = { message, includeAppLogs, _ -> + onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( message = message, - attachments = selectedDetailImagePaths ) }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, @@ -250,15 +227,9 @@ class HESupportActivity : AppCompatActivity() { localPostId = null ) }, - selectedImagePaths = selectedDetailImageUris.map { it.toString() }, - onRemoveImage = { uriString -> - val index = selectedDetailImageUris.indexOfFirst { it.toString() == uriString } - if (index >= 0) { - selectedDetailImageUris = - selectedDetailImageUris.filterIndexed { i, _ -> i != index } - selectedDetailImagePaths = - selectedDetailImagePaths.filterIndexed { i, _ -> i != index } - } + selectedImages = attachments.map { it.uri }, + onRemoveImage = { imageuri -> + viewModel.removeAttachment(imageuri) }, onDownloadAttachment = { attachment -> // Show loading snackbar @@ -281,24 +252,24 @@ class HESupportActivity : AppCompatActivity() { composable(route = ConversationScreen.NewTicket.name) { val userInfo by viewModel.userInfo.collectAsState() val isSendingNewConversation by viewModel.isSendingMessage.collectAsState() + val attachments by viewModel.attachments.collectAsState() - // Clear attachments when opening a new ticket + // Clear attachments when leaving the new ticket screen androidx.compose.runtime.DisposableEffect(Unit) { onDispose { - selectedNewTicketImageUris = emptyList() - selectedNewTicketImagePaths = emptyList() + viewModel.clearAttachments() } } HENewTicketScreen( snackbarHostState = snackbarHostState, onBackClick = { viewModel.onBackClick() }, - onSubmit = { category, subject, messageText, siteAddress, _ -> + onSubmit = { category, subject, messageText, siteAddress -> viewModel.onSendNewConversation( subject = subject, message = messageText, tags = listOf(category.key), - attachments = selectedNewTicketImagePaths + attachments = attachments.map { it.path } ) }, userName = userInfo.userName, @@ -313,15 +284,9 @@ class HESupportActivity : AppCompatActivity() { localPostId = null ) }, - selectedImagePaths = selectedNewTicketImageUris.map { it.toString() }, - onRemoveImage = { uriString -> - val index = selectedNewTicketImageUris.indexOfFirst { it.toString() == uriString } - if (index >= 0) { - selectedNewTicketImageUris = - selectedNewTicketImageUris.filterIndexed { i, _ -> i != index } - selectedNewTicketImagePaths = - selectedNewTicketImagePaths.filterIndexed { i, _ -> i != index } - } + selectedImages = attachments.map { it.uri }, + onRemoveImage = { imageUri -> + viewModel.removeAttachment(imageUri) }, ) } 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 a0c54123a968..ba0b6322d412 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,5 +1,6 @@ package org.wordpress.android.support.he.ui +import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -9,6 +10,7 @@ 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.ConversationsSupportViewModel +import org.wordpress.android.support.he.model.AttachmentUI import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository @@ -29,6 +31,10 @@ class HESupportViewModel @Inject constructor( private val _messageSendResult = MutableStateFlow(null) val messageSendResult: StateFlow = _messageSendResult.asStateFlow() + // Attachment state (shared for both Detail and NewTicket screens) + private val _attachments = MutableStateFlow>(emptyList()) + val attachments: StateFlow> = _attachments.asStateFlow() + sealed class MessageSendResult { data object Success : MessageSendResult() data object Failure : MessageSendResult() @@ -59,6 +65,8 @@ class HESupportViewModel @Inject constructor( val newConversation = result.conversation // update conversations locally _conversations.value = listOf(newConversation) + _conversations.value + // Clear attachments after successful creation + _attachments.value = emptyList() onBackClick() } @@ -80,10 +88,7 @@ class HESupportViewModel @Inject constructor( override suspend fun getConversation(conversationId: Long): SupportConversation? = heSupportRepository.loadConversation(conversationId) - fun onAddMessageToConversation( - message: String, - attachments: List - ) { + fun onAddMessageToConversation(message: String) { viewModelScope.launch { val selectedConversation = _selectedConversation.value if (selectedConversation == null) { @@ -96,11 +101,13 @@ class HESupportViewModel @Inject constructor( when (val result = heSupportRepository.addMessageToConversation( conversationId = selectedConversation.id, message = message, - attachments = attachments + attachments = _attachments.value.map { it.path } )) { is CreateConversationResult.Success -> { _selectedConversation.value = result.conversation _messageSendResult.value = MessageSendResult.Success + // Clear attachments after successful message send + _attachments.value = emptyList() } is CreateConversationResult.Error.Forbidden -> { @@ -123,4 +130,17 @@ class HESupportViewModel @Inject constructor( fun clearMessageSendResult() { _messageSendResult.value = null } + + // Attachment management functions + fun addAttachment(uri: Uri, path: String) { + _attachments.value = _attachments.value + AttachmentUI(uri, path) + } + + fun removeAttachment(uri: Uri) { + _attachments.value = _attachments.value.filter { it.uri != uri } + } + + fun clearAttachments() { + _attachments.value = emptyList() + } } 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 153682073331..b583f08d94c9 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 android.net.Uri import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -54,9 +55,9 @@ fun TicketMainContentView( onMessageChanged: (String) -> Unit, onIncludeAppLogsChanged: (Boolean) -> Unit, enabled: Boolean = true, - selectedImagePaths: List = emptyList(), + selectedImages: List = emptyList(), onAddImageClick: () -> Unit = { }, - onRemoveImage: (String) -> Unit = { }, + onRemoveImage: (Uri) -> Unit = { }, ) { Column( modifier = Modifier @@ -106,7 +107,7 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) - if (selectedImagePaths.isNotEmpty()) { + if (selectedImages.isNotEmpty()) { FlowRow( modifier = Modifier .fillMaxWidth() @@ -114,10 +115,10 @@ fun TicketMainContentView( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - selectedImagePaths.forEach { imagePath -> + selectedImages.forEach { imageUri -> ImagePreviewItem( - imagePath = imagePath, - onRemove = { onRemoveImage(imagePath) }, + imageUri = imageUri, + onRemove = { onRemoveImage(imageUri) }, enabled = enabled ) } @@ -216,7 +217,7 @@ fun TicketMainContentView( @Composable private fun ImagePreviewItem( - imagePath: String, + imageUri: Uri, onRemove: () -> Unit, enabled: Boolean = true ) { @@ -232,7 +233,7 @@ private fun ImagePreviewItem( ) ) { AsyncImage( - model = imagePath, + model = imageUri, contentDescription = stringResource(R.string.he_support_screenshot_preview), modifier = Modifier .size(100.dp) From 5dd14327ef197004b55422055342286f3b8684a9 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 13:34:19 +0100 Subject: [PATCH 122/153] Using a launcher instead of startActivityForResult --- .../support/he/ui/HESupportActivity.kt | 103 +++++++++++------- 1 file changed, 61 insertions(+), 42 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 e56bc837d0ee..a24a99940016 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 @@ -33,17 +33,15 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel -import org.wordpress.android.ui.photopicker.MediaPickerLauncher import org.wordpress.android.ui.photopicker.MediaPickerConstants import org.wordpress.android.ui.reader.ReaderFileDownloadManager -import org.wordpress.android.ui.RequestCodes -import org.wordpress.android.ui.media.MediaBrowserType +import org.wordpress.android.ui.mediapicker.MediaPickerSetup +import org.wordpress.android.ui.mediapicker.MediaType import org.wordpress.android.util.AppLog import javax.inject.Inject @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { - @Inject lateinit var mediaPickerLauncher: MediaPickerLauncher @Inject lateinit var fileDownloadManager: ReaderFileDownloadManager @Inject lateinit var appLogWrapper: AppLogWrapper private val viewModel by viewModels() @@ -51,6 +49,25 @@ class HESupportActivity : AppCompatActivity() { private lateinit var composeView: ComposeView private lateinit var navController: NavHostController + private val photoPickerLauncher = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK && result.data != null) { + val uris = result.data?.getStringArrayExtra(MediaPickerConstants.EXTRA_MEDIA_URIS) + uris?.let { uriStrings -> + lifecycleScope.launch { + val newUris = uriStrings.map { it.toUri() } + // Convert URIs to file paths and add to ViewModel + newUris.forEach { uri -> + copyUriToTempFile(uri)?.absolutePath?.let { path -> + viewModel.addAttachment(uri, path) + } + } + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) composeView = ComposeView(this) @@ -90,34 +107,6 @@ class HESupportActivity : AppCompatActivity() { } } - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (resultCode == RESULT_OK && data != null) { - when (requestCode) { - RequestCodes.PHOTO_PICKER -> { - // Handle media picker result based on current screen - val uris = data.getStringArrayExtra(MediaPickerConstants.EXTRA_MEDIA_URIS) - uris?.let { uriStrings -> - lifecycleScope.launch { - val newUris = uriStrings.map { it.toUri() } - - // Determine which screen is active by checking current destination - val currentDestination = navController.currentDestination?.route - - // Convert URIs to file paths and add to ViewModel - newUris.forEach { uri -> - copyUriToTempFile(uri)?.absolutePath?.let { path -> - viewModel.addAttachment(uri, path) - } - } - } - } - } - } - } - } @Suppress("TooGenericExceptionCaught") private fun copyUriToTempFile(uri: Uri): java.io.File? { @@ -220,12 +209,27 @@ class HESupportActivity : AppCompatActivity() { }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, onAddImageClick = { - mediaPickerLauncher.showPhotoPickerForResult( - activity = this@HESupportActivity, - browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, - site = null, - localPostId = null + val mediaPickerSetup = MediaPickerSetup( + primaryDataSource = MediaPickerSetup.DataSource.DEVICE, + availableDataSources = setOf(), + canMultiselect = true, + requiresPhotosVideosPermissions = true, + requiresMusicAudioPermissions = false, + allowedTypes = setOf(MediaType.IMAGE), + cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, + systemPickerEnabled = true, + editingEnabled = true, + queueResults = false, + defaultSearchView = false, + title = R.string.photo_picker_title ) + val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( + this@HESupportActivity, + mediaPickerSetup, + null, + null + ) + photoPickerLauncher.launch(intent) }, selectedImages = attachments.map { it.uri }, onRemoveImage = { imageuri -> @@ -277,12 +281,27 @@ class HESupportActivity : AppCompatActivity() { userAvatarUrl = userInfo.avatarUrl, isSendingNewConversation = isSendingNewConversation, onAddImageClick = { - mediaPickerLauncher.showPhotoPickerForResult( - activity = this@HESupportActivity, - browserType = MediaBrowserType.FEEDBACK_FORM_MEDIA_PICKER, - site = null, - localPostId = null + val mediaPickerSetup = MediaPickerSetup( + primaryDataSource = MediaPickerSetup.DataSource.DEVICE, + availableDataSources = setOf(), + canMultiselect = true, + requiresPhotosVideosPermissions = true, + requiresMusicAudioPermissions = false, + allowedTypes = setOf(MediaType.IMAGE), + cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, + systemPickerEnabled = true, + editingEnabled = true, + queueResults = false, + defaultSearchView = false, + title = R.string.photo_picker_title + ) + val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( + this@HESupportActivity, + mediaPickerSetup, + null, + null ) + photoPickerLauncher.launch(intent) }, selectedImages = attachments.map { it.uri }, onRemoveImage = { imageUri -> From 72bad4378e994ca6dbbb87cf29c328c1489ef524 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 13:36:45 +0100 Subject: [PATCH 123/153] Remove unused parameter --- .../org/wordpress/android/support/he/ui/HESupportActivity.kt | 1 - .../org/wordpress/android/support/he/ui/HESupportViewModel.kt | 3 +-- 2 files changed, 1 insertion(+), 3 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 a24a99940016..4434730fff82 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 @@ -273,7 +273,6 @@ class HESupportActivity : AppCompatActivity() { subject = subject, message = messageText, tags = listOf(category.key), - attachments = attachments.map { it.path } ) }, userName = userInfo.userName, 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 ba0b6322d412..7eba3f7ed355 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 @@ -50,7 +50,6 @@ class HESupportViewModel @Inject constructor( subject: String, message: String, tags: List, - attachments: List ) { viewModelScope.launch { _isSendingMessage.value = true @@ -59,7 +58,7 @@ class HESupportViewModel @Inject constructor( subject = subject, message = message, tags = tags, - attachments = attachments + attachments = attachments.value.map { it.path } )) { is CreateConversationResult.Success -> { val newConversation = result.conversation From f7795dbc75284a4e8c5e5c58ae3e00cc357a6d0f Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 16:21:17 +0100 Subject: [PATCH 124/153] Handling temp files inside the VM --- .../android/support/he/model/AttachmentUI.kt | 8 --- .../he/ui/HEConversationDetailScreen.kt | 8 +-- .../support/he/ui/HENewTicketScreen.kt | 4 +- .../support/he/ui/HESupportActivity.kt | 37 ++--------- .../support/he/ui/HESupportViewModel.kt | 63 ++++++++++++++++--- .../support/he/ui/TicketMainContentView.kt | 6 +- 6 files changed, 66 insertions(+), 60 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt deleted file mode 100644 index d509ace90d68..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/support/he/model/AttachmentUI.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.wordpress.android.support.he.model - -import android.net.Uri - -data class AttachmentUI( - val uri: Uri, - val path: String -) 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 8dfbc30b5dee..54840b02fe6f 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 @@ -91,7 +91,7 @@ fun HEConversationDetailScreen( onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit, onClearMessageSendResult: () -> Unit = {}, onAddImageClick: () -> Unit = {}, - selectedImages: List = emptyList(), + attachments: List = emptyList(), onRemoveImage: (Uri) -> Unit = {}, onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} ) { @@ -209,7 +209,7 @@ fun HEConversationDetailScreen( onClearMessageSendResult() }, onAddImageClick = onAddImageClick, - selectedImages = selectedImages, + attachments = attachments, onRemoveImage = onRemoveImage ) } @@ -509,7 +509,7 @@ private fun ReplyBottomSheet( onSend: (String, Boolean) -> Unit, onMessageSentSuccessfully: () -> Unit, onAddImageClick: () -> Unit = {}, - selectedImages: List = emptyList(), + attachments: List = emptyList(), onRemoveImage: (Uri) -> Unit = {} ) { var messageText by remember { mutableStateOf(initialMessageText) } @@ -595,7 +595,7 @@ private fun ReplyBottomSheet( onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, enabled = !isSending, - selectedImages = selectedImages, + attachments = attachments, onAddImageClick = onAddImageClick, onRemoveImage = onRemoveImage ) 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 94682d50df20..b1a2a00cae65 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 @@ -75,7 +75,7 @@ fun HENewTicketScreen( userAvatarUrl: String? = null, isSendingNewConversation: Boolean = false, onAddImageClick: () -> Unit = { }, - selectedImages: List = emptyList(), + attachments: List = emptyList(), onRemoveImage: (Uri) -> Unit = { } ) { var selectedCategory by remember { mutableStateOf(null) } @@ -198,7 +198,7 @@ fun HENewTicketScreen( includeAppLogs = includeAppLogs, onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, - selectedImages = selectedImages, + attachments = attachments, onAddImageClick = onAddImageClick, onRemoveImage = onRemoveImage ) 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 4434730fff82..618f36f9440f 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 @@ -2,7 +2,6 @@ package org.wordpress.android.support.he.ui import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Build import androidx.core.net.toUri import android.os.Bundle @@ -37,7 +36,6 @@ import org.wordpress.android.ui.photopicker.MediaPickerConstants import org.wordpress.android.ui.reader.ReaderFileDownloadManager import org.wordpress.android.ui.mediapicker.MediaPickerSetup import org.wordpress.android.ui.mediapicker.MediaType -import org.wordpress.android.util.AppLog import javax.inject.Inject @AndroidEntryPoint @@ -55,15 +53,8 @@ class HESupportActivity : AppCompatActivity() { if (result.resultCode == RESULT_OK && result.data != null) { val uris = result.data?.getStringArrayExtra(MediaPickerConstants.EXTRA_MEDIA_URIS) uris?.let { uriStrings -> - lifecycleScope.launch { - val newUris = uriStrings.map { it.toUri() } - // Convert URIs to file paths and add to ViewModel - newUris.forEach { uri -> - copyUriToTempFile(uri)?.absolutePath?.let { path -> - viewModel.addAttachment(uri, path) - } - } - } + val newUris = uriStrings.map { it.toUri() } + viewModel.addAttachments(newUris) } } } @@ -107,26 +98,6 @@ class HESupportActivity : AppCompatActivity() { } } - - @Suppress("TooGenericExceptionCaught") - private fun copyUriToTempFile(uri: Uri): java.io.File? { - return try { - val inputStream = contentResolver.openInputStream(uri) ?: return null - val fileName = "support_image_${System.currentTimeMillis()}.jpg" - val tempFile = java.io.File(cacheDir, fileName) - - tempFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - inputStream.close() - - tempFile - } catch (e: Exception) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URI to temp file: ${e.stackTraceToString()}") - null - } - } - private enum class ConversationScreen { List, Detail, @@ -231,7 +202,7 @@ class HESupportActivity : AppCompatActivity() { ) photoPickerLauncher.launch(intent) }, - selectedImages = attachments.map { it.uri }, + attachments = attachments, onRemoveImage = { imageuri -> viewModel.removeAttachment(imageuri) }, @@ -302,7 +273,7 @@ class HESupportActivity : AppCompatActivity() { ) photoPickerLauncher.launch(intent) }, - selectedImages = attachments.map { it.uri }, + attachments = attachments, onRemoveImage = { imageUri -> viewModel.removeAttachment(imageUri) }, 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 7eba3f7ed355..b6baeebed55c 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,5 +1,6 @@ package org.wordpress.android.support.he.ui +import android.app.Application import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -10,18 +11,19 @@ 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.ConversationsSupportViewModel -import org.wordpress.android.support.he.model.AttachmentUI import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper +import java.io.File import javax.inject.Inject @HiltViewModel class HESupportViewModel @Inject constructor( - accountStore: AccountStore, private val heSupportRepository: HESupportRepository, + private val application: Application, + accountStore: AccountStore, appLogWrapper: AppLogWrapper, networkUtilsWrapper: NetworkUtilsWrapper, ) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { @@ -32,8 +34,8 @@ class HESupportViewModel @Inject constructor( val messageSendResult: StateFlow = _messageSendResult.asStateFlow() // Attachment state (shared for both Detail and NewTicket screens) - private val _attachments = MutableStateFlow>(emptyList()) - val attachments: StateFlow> = _attachments.asStateFlow() + private val _attachments = MutableStateFlow>(emptyList()) + val attachments: StateFlow> = _attachments.asStateFlow() sealed class MessageSendResult { data object Success : MessageSendResult() @@ -54,11 +56,13 @@ class HESupportViewModel @Inject constructor( viewModelScope.launch { _isSendingMessage.value = true + val files = copyUrisToTempFiles(_attachments.value) + when (val result = heSupportRepository.createConversation( subject = subject, message = message, tags = tags, - attachments = attachments.value.map { it.path } + attachments = files.map { it.path } )) { is CreateConversationResult.Success -> { val newConversation = result.conversation @@ -80,6 +84,7 @@ class HESupportViewModel @Inject constructor( } } + removeTempFiles(files) _isSendingMessage.value = false } } @@ -96,11 +101,12 @@ class HESupportViewModel @Inject constructor( } _isSendingMessage.value = true + val files = copyUrisToTempFiles(_attachments.value) when (val result = heSupportRepository.addMessageToConversation( conversationId = selectedConversation.id, message = message, - attachments = _attachments.value.map { it.path } + attachments = files.map { it.path } )) { is CreateConversationResult.Success -> { _selectedConversation.value = result.conversation @@ -122,6 +128,7 @@ class HESupportViewModel @Inject constructor( } } + removeTempFiles(files) _isSendingMessage.value = false } } @@ -130,16 +137,52 @@ class HESupportViewModel @Inject constructor( _messageSendResult.value = null } - // Attachment management functions - fun addAttachment(uri: Uri, path: String) { - _attachments.value = _attachments.value + AttachmentUI(uri, path) + fun addAttachments(uris: List) { + _attachments.value = _attachments.value + uris } fun removeAttachment(uri: Uri) { - _attachments.value = _attachments.value.filter { it.uri != uri } + _attachments.value = _attachments.value.filter { it != uri } } fun clearAttachments() { _attachments.value = emptyList() } + + @Suppress("TooGenericExceptionCaught") + private fun copyUrisToTempFiles(uris: List): List { + return try { + uris.mapNotNull { it.toTempFile() } + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URIs to temp files: ${e.stackTraceToString()}") + emptyList() + } + } + + private fun removeTempFiles(files: List) { + return try { + // Remove temp files + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error removing attachment temp files temp files: ${e.stackTraceToString()}") + } + } + + @Suppress("TooGenericExceptionCaught") + private fun Uri.toTempFile(): File? { + return try { + val inputStream = application.contentResolver.openInputStream(this) ?: return null + val fileName = "support_image_${System.currentTimeMillis()}.jpg" + val tempFile = File(application.cacheDir, fileName) + + tempFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + inputStream.close() + + tempFile + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URI to temp file: ${e.stackTraceToString()}") + null + } + } } 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 b583f08d94c9..5905a7ba5676 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 @@ -55,7 +55,7 @@ fun TicketMainContentView( onMessageChanged: (String) -> Unit, onIncludeAppLogsChanged: (Boolean) -> Unit, enabled: Boolean = true, - selectedImages: List = emptyList(), + attachments: List = emptyList(), onAddImageClick: () -> Unit = { }, onRemoveImage: (Uri) -> Unit = { }, ) { @@ -107,7 +107,7 @@ fun TicketMainContentView( modifier = Modifier.padding(bottom = 12.dp) ) - if (selectedImages.isNotEmpty()) { + if (attachments.isNotEmpty()) { FlowRow( modifier = Modifier .fillMaxWidth() @@ -115,7 +115,7 @@ fun TicketMainContentView( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - selectedImages.forEach { imageUri -> + attachments.forEach { imageUri -> ImagePreviewItem( imageUri = imageUri, onRemove = { onRemoveImage(imageUri) }, From 94bb5d84f7ceacbb72fdba1f2f18f417337c6116 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 16:34:54 +0100 Subject: [PATCH 125/153] Removing files --- .../support/he/ui/HESupportViewModel.kt | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) 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 b6baeebed55c..82b84c3a0d80 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 @@ -4,12 +4,15 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult @@ -18,11 +21,13 @@ import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper import java.io.File import javax.inject.Inject +import javax.inject.Named @HiltViewModel class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, private val application: Application, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, accountStore: AccountStore, appLogWrapper: AppLogWrapper, networkUtilsWrapper: NetworkUtilsWrapper, @@ -150,8 +155,8 @@ class HESupportViewModel @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private fun copyUrisToTempFiles(uris: List): List { - return try { + private suspend fun copyUrisToTempFiles(uris: List): List = withContext(ioDispatcher) { + try { uris.mapNotNull { it.toTempFile() } } catch (e: Exception) { appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URIs to temp files: ${e.stackTraceToString()}") @@ -159,18 +164,22 @@ class HESupportViewModel @Inject constructor( } } - private fun removeTempFiles(files: List) { - return try { - // Remove temp files + private suspend fun removeTempFiles(files: List) = withContext(ioDispatcher) { + try { + files.forEach { file -> + if (file.exists()) { + file.delete() + } + } } catch (e: Exception) { appLogWrapper.e(AppLog.T.SUPPORT, "Error removing attachment temp files temp files: ${e.stackTraceToString()}") } } @Suppress("TooGenericExceptionCaught") - private fun Uri.toTempFile(): File? { - return try { - val inputStream = application.contentResolver.openInputStream(this) ?: return null + private suspend fun Uri.toTempFile(): File? = withContext(ioDispatcher) { + try { + val inputStream = application.contentResolver.openInputStream(this@toTempFile) ?: return@withContext null val fileName = "support_image_${System.currentTimeMillis()}.jpg" val tempFile = File(application.cacheDir, fileName) From 5a51b11661e0a0fb5e6ea9724eb897bbf993f648 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 16:35:56 +0100 Subject: [PATCH 126/153] detekt --- .../org/wordpress/android/support/he/ui/HESupportActivity.kt | 3 --- .../org/wordpress/android/support/he/ui/HESupportViewModel.kt | 4 +++- 2 files changed, 3 insertions(+), 4 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 618f36f9440f..b64804535589 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 @@ -10,13 +10,10 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 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/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index 82b84c3a0d80..3894313fb6c4 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 @@ -164,6 +164,7 @@ class HESupportViewModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private suspend fun removeTempFiles(files: List) = withContext(ioDispatcher) { try { files.forEach { file -> @@ -172,7 +173,8 @@ class HESupportViewModel @Inject constructor( } } } catch (e: Exception) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error removing attachment temp files temp files: ${e.stackTraceToString()}") + appLogWrapper.e(AppLog.T.SUPPORT, "Error removing attachment temp files temp files: " + + e.stackTraceToString()) } } From d1a9aaf9d71428bde9136a18fb950c449b84265e Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 16:38:30 +0100 Subject: [PATCH 127/153] Throwing copy file error --- .../android/support/he/ui/HESupportViewModel.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 3894313fb6c4..eade0593369c 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 @@ -156,12 +156,7 @@ class HESupportViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") private suspend fun copyUrisToTempFiles(uris: List): List = withContext(ioDispatcher) { - try { - uris.mapNotNull { it.toTempFile() } - } catch (e: Exception) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URIs to temp files: ${e.stackTraceToString()}") - emptyList() - } + uris.mapNotNull { it.toTempFile() } } @Suppress("TooGenericExceptionCaught") @@ -193,7 +188,7 @@ class HESupportViewModel @Inject constructor( tempFile } catch (e: Exception) { appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URI to temp file: ${e.stackTraceToString()}") - null + throw e } } } From 310d9dff40dcf6fe66bf9cc6ac5ca848b4329360 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 16:52:52 +0100 Subject: [PATCH 128/153] Extracting some individual composables from HEConversation screen file --- .../he/ui/AttachmentFullscreenImagePreview.kt | 193 ++++++++++++ .../he/ui/HEConversationDetailScreen.kt | 275 +----------------- .../he/ui/HEConversationReplyBottomSheet.kt | 138 +++++++++ 3 files changed, 333 insertions(+), 273 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt new file mode 100644 index 000000000000..5c98026e1cf4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt @@ -0,0 +1,193 @@ +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.clickable +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@Composable +fun AttachmentFullscreenImagePreview( + imageUrl: String, + onDismiss: () -> Unit, + onDownload: () -> Unit = {} +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + // Load semantics + val loadingImageDescription = stringResource(R.string.he_support_loading_image) + val attachmentImageDescription = stringResource(R.string.he_support_attachment_image) + val failedToLoadImageDescription = stringResource(R.string.he_support_failed_to_load_image) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onDismiss), + color = Color.Black + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .semantics { + contentDescription = loadingImageDescription + } + ) + // Zoomable image + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = attachmentImageDescription, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } + } + }, + contentScale = ContentScale.Fit, + error = { + Icon( + painter = painterResource(R.drawable.ic_image_white_24dp), + contentDescription = failedToLoadImageDescription, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + } + ) + } + + // Top bar with close button + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(24.dp) + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Download button + IconButton( + onClick = { + onDownload.invoke() + onDismiss.invoke() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_get_app_white_24dp), + contentDescription = stringResource(R.string.he_support_download_image), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + + // Close button + IconButton( + onClick = onDismiss + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.close), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } +} + +@Preview(showBackground = true, name = "Fullscreen Image Preview") +@Composable +private fun AttachmentFullscreenImagePreviewPreview() { + AppThemeM3(isDarkTheme = false) { + AttachmentFullscreenImagePreview( + imageUrl = "https://via.placeholder.com/800x600", + onDismiss = { }, + onDownload = { } + ) + } +} + +@Preview(showBackground = true, name = "Fullscreen Image Preview - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AttachmentFullscreenImagePreviewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + AttachmentFullscreenImagePreview( + imageUrl = "https://via.placeholder.com/800x600", + onDismiss = { }, + onDownload = { } + ) + } +} 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 54840b02fe6f..b69c2fec5532 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 @@ -4,7 +4,6 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,37 +12,28 @@ 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.layout.FlowRow 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.material.icons.filled.Close import androidx.compose.material3.Button 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.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface 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.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -52,9 +42,6 @@ import coil.compose.SubcomposeAsyncImage import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources @@ -67,8 +54,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import coil.request.ImageRequest import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime @@ -183,7 +168,7 @@ fun HEConversationDetailScreen( } if (showBottomSheet) { - ReplyBottomSheet( + HEConversationReplyBottomSheet( sheetState = sheetState, isSending = isSendingMessage, messageSendResult = messageSendResult, @@ -221,7 +206,7 @@ fun HEConversationDetailScreen( .flatMap { it.attachments } .firstOrNull { it.url == imageUrl } - FullscreenImagePreview( + AttachmentFullscreenImagePreview( imageUrl = imageUrl, onDismiss = { previewImageUrl = null }, onDownload = { @@ -497,262 +482,6 @@ private fun ReplyButton( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -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, - onMessageSentSuccessfully: () -> Unit, - onAddImageClick: () -> Unit = {}, - attachments: List = emptyList(), - onRemoveImage: (Uri) -> Unit = {} -) { - var messageText by remember { mutableStateOf(initialMessageText) } - var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } - val scrollState = rememberScrollState() - - // Close the sheet when sending completes successfully - 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 - } - } - } - - ModalBottomSheet( - onDismissRequest = { onDismiss(messageText, includeAppLogs) }, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .imePadding() - .verticalScroll(scrollState) - .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(messageText, includeAppLogs) }, - enabled = !isSending - ) { - 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, - modifier = Modifier.semantics { heading() } - ) - - TextButton( - onClick = { onSend(messageText, includeAppLogs) }, - enabled = messageText.isNotBlank() && !isSending - ) { - 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 - ) - } - } - } - - TicketMainContentView( - messageText = messageText, - includeAppLogs = includeAppLogs, - onMessageChanged = { message -> messageText = message }, - onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, - enabled = !isSending, - attachments = attachments, - onAddImageClick = onAddImageClick, - onRemoveImage = onRemoveImage - ) - } - } -} - -@Composable -private fun FullscreenImagePreview( - imageUrl: String, - onDismiss: () -> Unit, - onDownload: () -> Unit = {} -) { - var scale by remember { mutableFloatStateOf(1f) } - var offsetX by remember { mutableFloatStateOf(0f) } - var offsetY by remember { mutableFloatStateOf(0f) } - - // Load semantics - val loadingImageDescription = stringResource(R.string.he_support_loading_image) - val attachmentImageDescription = stringResource(R.string.he_support_attachment_image) - val failedToLoadImageDescription = stringResource(R.string.he_support_failed_to_load_image) - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnBackPress = true, - dismissOnClickOutside = false - ) - ) { - Surface( - modifier = Modifier - .fillMaxSize() - .clickable(onClick = onDismiss), - color = Color.Black - ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator( - modifier = Modifier - .align(Alignment.Center) - .semantics { - contentDescription = loadingImageDescription - } - ) - // Zoomable image - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - SubcomposeAsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), - contentDescription = attachmentImageDescription, - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY - ) - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(1f, 5f) - if (scale > 1f) { - offsetX += pan.x - offsetY += pan.y - } else { - offsetX = 0f - offsetY = 0f - } - } - }, - contentScale = ContentScale.Fit, - error = { - Icon( - painter = painterResource(R.drawable.ic_image_white_24dp), - contentDescription = failedToLoadImageDescription, - tint = Color.White, - modifier = Modifier.size(48.dp) - ) - } - ) - } - - // Top bar with close button - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(16.dp) - .background( - color = Color.Black.copy(alpha = 0.5f), - shape = RoundedCornerShape(24.dp) - ) - .padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Download button - IconButton( - onClick = { - onDownload.invoke() - onDismiss.invoke() - } - ) { - Icon( - painter = painterResource(R.drawable.ic_get_app_white_24dp), - contentDescription = stringResource(R.string.he_support_download_image), - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } - - // Close button - IconButton( - onClick = onDismiss - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.close), - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } - } - } - } - } -} - -@Preview(showBackground = true, name = "Fullscreen Image Preview") -@Composable -private fun FullscreenImagePreviewPreview() { - AppThemeM3(isDarkTheme = false) { - FullscreenImagePreview( - imageUrl = "https://via.placeholder.com/800x600", - onDismiss = { }, - onDownload = { } - ) - } -} - -@Preview(showBackground = true, name = "Fullscreen Image Preview - Dark", uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun FullscreenImagePreviewPreviewDark() { - AppThemeM3(isDarkTheme = true) { - FullscreenImagePreview( - imageUrl = "https://via.placeholder.com/800x600", - onDismiss = { }, - onDownload = { } - ) - } -} - @Preview(showBackground = true, name = "HE Conversation Detail") @Composable private fun HEConversationDetailScreenPreview() { diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt new file mode 100644 index 000000000000..bbf29bce74c3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -0,0 +1,138 @@ +package org.wordpress.android.support.he.ui + +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +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.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.wordpress.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HEConversationReplyBottomSheet( + 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, + onMessageSentSuccessfully: () -> Unit, + onAddImageClick: () -> Unit = {}, + attachments: List = emptyList(), + onRemoveImage: (Uri) -> Unit = {} +) { + var messageText by remember { mutableStateOf(initialMessageText) } + var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } + val scrollState = rememberScrollState() + + // Close the sheet when sending completes successfully + 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 + } + } + } + + ModalBottomSheet( + onDismissRequest = { onDismiss(messageText, includeAppLogs) }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .verticalScroll(scrollState) + .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(messageText, includeAppLogs) }, + enabled = !isSending + ) { + 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, + modifier = Modifier.semantics { heading() } + ) + + TextButton( + onClick = { onSend(messageText, includeAppLogs) }, + enabled = messageText.isNotBlank() && !isSending + ) { + 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 + ) + } + } + } + + TicketMainContentView( + messageText = messageText, + includeAppLogs = includeAppLogs, + onMessageChanged = { message -> messageText = message }, + onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, + enabled = !isSending, + attachments = attachments, + onAddImageClick = onAddImageClick, + onRemoveImage = onRemoveImage + ) + } + } +} From 09f138a6e390cce07ce20038ac1c1c212c7b87b6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 17:03:34 +0100 Subject: [PATCH 129/153] Reducing arguments --- .../he/ui/HEConversationReplyBottomSheet.kt | 6 ++-- .../support/he/ui/HENewTicketScreen.kt | 33 ++++++++----------- .../support/he/ui/TicketMainContentView.kt | 17 +++++++--- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index bbf29bce74c3..4b557faeb401 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -130,8 +130,10 @@ fun HEConversationReplyBottomSheet( onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, enabled = !isSending, attachments = attachments, - onAddImageClick = onAddImageClick, - onRemoveImage = onRemoveImage + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() = onAddImageClick() + override fun onRemoveImage(uri: Uri) = onRemoveImage(uri) + } ) } } 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 b1a2a00cae65..57cf23b70302 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 @@ -54,6 +54,7 @@ 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.support.common.model.UserInfo import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -70,9 +71,7 @@ fun HENewTicketScreen( messageText: String, siteAddress: String, ) -> Unit, - userName: String = "", - userEmail: String = "", - userAvatarUrl: String? = null, + userInfo: UserInfo, isSendingNewConversation: Boolean = false, onAddImageClick: () -> Unit = { }, attachments: List = emptyList(), @@ -199,8 +198,10 @@ fun HENewTicketScreen( onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, attachments = attachments, - onAddImageClick = onAddImageClick, - onRemoveImage = onRemoveImage + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() = onAddImageClick() + override fun onRemoveImage(uri: Uri) = onRemoveImage(uri) + } ) Spacer(modifier = Modifier.height(32.dp)) @@ -208,9 +209,9 @@ fun HENewTicketScreen( SectionHeader(text = stringResource(R.string.he_support_contact_information)) ContactInformationCard( - userName = userName, - userEmail = userEmail, - userAvatarUrl = userAvatarUrl + userName = userInfo.userName, + userEmail = userInfo.userEmail, + userAvatarUrl = userInfo.avatarUrl ) Spacer(modifier = Modifier.height(24.dp)) @@ -426,9 +427,7 @@ private fun HENewTicketScreenPreview() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _-> }, - userName = "Test user", - userEmail = "test.user@automattic.com", - userAvatarUrl = null + userInfo = UserInfo("Test user", "test.user@automattic.com", null) ) } } @@ -442,9 +441,7 @@ private fun HENewTicketScreenPreviewDark() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, - userName = "Test user", - userEmail = "test.user@automattic.com", - userAvatarUrl = null + userInfo = UserInfo("Test user", "test.user@automattic.com", null) ) } } @@ -458,9 +455,7 @@ private fun HENewTicketScreenWordPressPreview() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, - userName = "Test user", - userEmail = "test.user@automattic.com", - userAvatarUrl = null + userInfo = UserInfo("Test user", "test.user@automattic.com", null) ) } } @@ -474,9 +469,7 @@ private fun HENewTicketScreenPreviewWordPressDark() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, - userName = "Test user", - userEmail = "test.user@automattic.com", - userAvatarUrl = null + userInfo = UserInfo("Test user", "test.user@automattic.com", null) ) } } 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 5905a7ba5676..b9d6665f2200 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 @@ -47,6 +47,11 @@ import coil.compose.AsyncImage import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 +interface AttachmentActionsListener { + fun onAddImageClick() + fun onRemoveImage(uri: Uri) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TicketMainContentView( @@ -56,9 +61,11 @@ fun TicketMainContentView( onIncludeAppLogsChanged: (Boolean) -> Unit, enabled: Boolean = true, attachments: List = emptyList(), - onAddImageClick: () -> Unit = { }, - onRemoveImage: (Uri) -> Unit = { }, - ) { + attachmentActionsListener: AttachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() {} + override fun onRemoveImage(uri: Uri) {} + } +) { Column( modifier = Modifier .fillMaxWidth() @@ -118,7 +125,7 @@ fun TicketMainContentView( attachments.forEach { imageUri -> ImagePreviewItem( imageUri = imageUri, - onRemove = { onRemoveImage(imageUri) }, + onRemove = { attachmentActionsListener.onRemoveImage(imageUri) }, enabled = enabled ) } @@ -127,7 +134,7 @@ fun TicketMainContentView( val addScreenshotsLabel = stringResource(R.string.he_support_add_screenshots_button) OutlinedButton( - onClick = onAddImageClick, + onClick = attachmentActionsListener::onAddImageClick, modifier = Modifier .fillMaxWidth() .height(48.dp) From b0ebf825050ba20598dca13e2d6d6f2a593ea7c4 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 17:08:44 +0100 Subject: [PATCH 130/153] Catch file creation error --- .../support/he/ui/HESupportViewModel.kt | 138 ++++++++++-------- 1 file changed, 75 insertions(+), 63 deletions(-) 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 eade0593369c..3b90fbf5e61f 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 @@ -59,38 +59,43 @@ class HESupportViewModel @Inject constructor( tags: List, ) { viewModelScope.launch { - _isSendingMessage.value = true - - val files = copyUrisToTempFiles(_attachments.value) - - when (val result = heSupportRepository.createConversation( - subject = subject, - message = message, - tags = tags, - attachments = files.map { it.path } - )) { - is CreateConversationResult.Success -> { - val newConversation = result.conversation - // update conversations locally - _conversations.value = listOf(newConversation) + _conversations.value - // Clear attachments after successful creation - _attachments.value = emptyList() - onBackClick() + try { + _isSendingMessage.value = true + + val files = copyUrisToTempFiles(_attachments.value) + + when (val result = heSupportRepository.createConversation( + subject = subject, + message = message, + tags = tags, + attachments = files.map { it.path } + )) { + is CreateConversationResult.Success -> { + val newConversation = result.conversation + // update conversations locally + _conversations.value = listOf(newConversation) + _conversations.value + // Clear attachments after successful creation + _attachments.value = emptyList() + onBackClick() + } + + is CreateConversationResult.Error.Forbidden -> { + _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, "General error creating HE conversation") + } } - is CreateConversationResult.Error.Forbidden -> { - _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, "General error creating HE conversation") - } + removeTempFiles(files) + _isSendingMessage.value = false + } catch (e: Exception) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error creating HE conversation") } - - removeTempFiles(files) - _isSendingMessage.value = false } } @@ -99,42 +104,48 @@ class HESupportViewModel @Inject constructor( fun onAddMessageToConversation(message: String) { viewModelScope.launch { - val selectedConversation = _selectedConversation.value - if (selectedConversation == null) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error answering a conversation: no conversation selected") - return@launch - } - - _isSendingMessage.value = true - val files = copyUrisToTempFiles(_attachments.value) - - when (val result = heSupportRepository.addMessageToConversation( - conversationId = selectedConversation.id, - message = message, - attachments = files.map { it.path } - )) { - is CreateConversationResult.Success -> { - _selectedConversation.value = result.conversation - _messageSendResult.value = MessageSendResult.Success - // Clear attachments after successful message send - _attachments.value = emptyList() + try { + val selectedConversation = _selectedConversation.value + if (selectedConversation == null) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error answering a conversation: no conversation selected") + return@launch } - is CreateConversationResult.Error.Forbidden -> { - _errorMessage.value = ErrorType.FORBIDDEN - appLogWrapper.e(AppLog.T.SUPPORT, "Unauthorized error adding message to HE conversation") - _messageSendResult.value = MessageSendResult.Failure + _isSendingMessage.value = true + val files = copyUrisToTempFiles(_attachments.value) + + when (val result = heSupportRepository.addMessageToConversation( + conversationId = selectedConversation.id, + message = message, + attachments = files.map { it.path } + )) { + is CreateConversationResult.Success -> { + _selectedConversation.value = result.conversation + _messageSendResult.value = MessageSendResult.Success + // Clear attachments after successful message send + _attachments.value = emptyList() + } + + 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 + } } - is CreateConversationResult.Error.GeneralError -> { - _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "General error adding message to HE conversation") - _messageSendResult.value = MessageSendResult.Failure - } + removeTempFiles(files) + _isSendingMessage.value = false + } catch (e: Exception) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error adding message to HE conversation: " + + e.stackTraceToString()) } - - removeTempFiles(files) - _isSendingMessage.value = false } } @@ -156,7 +167,7 @@ class HESupportViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") private suspend fun copyUrisToTempFiles(uris: List): List = withContext(ioDispatcher) { - uris.mapNotNull { it.toTempFile() } + uris.map{ it.toTempFile() } } @Suppress("TooGenericExceptionCaught") @@ -174,9 +185,10 @@ class HESupportViewModel @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private suspend fun Uri.toTempFile(): File? = withContext(ioDispatcher) { + private suspend fun Uri.toTempFile(): File = withContext(ioDispatcher) { try { - val inputStream = application.contentResolver.openInputStream(this@toTempFile) ?: return@withContext null + val inputStream = application.contentResolver.openInputStream(this@toTempFile) + ?: throw Exception("Failed to open input stream for attachment") val fileName = "support_image_${System.currentTimeMillis()}.jpg" val tempFile = File(application.cacheDir, fileName) From 848235fced7e0ee4a50d4f58b7e8c0de4d3d1a34 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 17:10:45 +0100 Subject: [PATCH 131/153] Using proper file extension --- .../support/he/ui/HESupportViewModel.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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 3b90fbf5e61f..8280fdceaa90 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 @@ -189,7 +189,10 @@ class HESupportViewModel @Inject constructor( try { val inputStream = application.contentResolver.openInputStream(this@toTempFile) ?: throw Exception("Failed to open input stream for attachment") - val fileName = "support_image_${System.currentTimeMillis()}.jpg" + + // Get file extension from MIME type or URI + val extension = getFileExtension() + val fileName = "support_attachment_${System.currentTimeMillis()}.$extension" val tempFile = File(application.cacheDir, fileName) tempFile.outputStream().use { outputStream -> @@ -203,4 +206,27 @@ class HESupportViewModel @Inject constructor( throw e } } + + private fun Uri.getFileExtension(): String { + // First, try to get extension from MIME type + val mimeType = application.contentResolver.getType(this) + mimeType?.let { type -> + val extension = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(type) + if (!extension.isNullOrEmpty()) { + return extension + } + } + + // Fallback: try to extract extension from the URI path + val path = this.path + path?.let { + val lastDotIndex = it.lastIndexOf('.') + if (lastDotIndex > 0 && lastDotIndex < it.length - 1) { + return it.substring(lastDotIndex + 1) + } + } + + // Default to jpg if we can't determine the extension + return "jpg" + } } From 9a502b2002361cecb9c97d2b54be4ad62fb78f95 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 17:19:29 +0100 Subject: [PATCH 132/153] General improvements --- .../he/ui/HEConversationReplyBottomSheet.kt | 1 + .../support/he/ui/HENewTicketScreen.kt | 1 + .../support/he/ui/HESupportActivity.kt | 4 +-- .../support/he/ui/HESupportViewModel.kt | 9 ++++--- .../support/he/ui/TicketMainContentView.kt | 25 +++++++++++-------- .../he/util/AttachmentActionsListener.kt | 8 ++++++ 6 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/util/AttachmentActionsListener.kt diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index 4b557faeb401..80e4bd64205e 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.wordpress.android.R +import org.wordpress.android.support.he.util.AttachmentActionsListener @OptIn(ExperimentalMaterial3Api::class) @Composable 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 57cf23b70302..4841043ddfe1 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 @@ -55,6 +55,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import org.wordpress.android.R import org.wordpress.android.support.common.model.UserInfo +import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 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 b64804535589..3380dd24ab5d 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 @@ -243,9 +243,7 @@ class HESupportActivity : AppCompatActivity() { tags = listOf(category.key), ) }, - userName = userInfo.userName, - userEmail = userInfo.userEmail, - userAvatarUrl = userInfo.avatarUrl, + userInfo = userInfo, isSendingNewConversation = isSendingNewConversation, onAddImageClick = { val mediaPickerSetup = MediaPickerSetup( 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 8280fdceaa90..e4e9d2789ef0 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 @@ -53,12 +53,13 @@ class HESupportViewModel @Inject constructor( override suspend fun getConversations(): List = heSupportRepository.loadConversations() + @Suppress("TooGenericExceptionCaught") fun onSendNewConversation( subject: String, message: String, tags: List, ) { - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { try { _isSendingMessage.value = true @@ -102,8 +103,9 @@ class HESupportViewModel @Inject constructor( override suspend fun getConversation(conversationId: Long): SupportConversation? = heSupportRepository.loadConversation(conversationId) + @Suppress("TooGenericExceptionCaught") fun onAddMessageToConversation(message: String) { - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { try { val selectedConversation = _selectedConversation.value if (selectedConversation == null) { @@ -184,7 +186,7 @@ class HESupportViewModel @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") private suspend fun Uri.toTempFile(): File = withContext(ioDispatcher) { try { val inputStream = application.contentResolver.openInputStream(this@toTempFile) @@ -207,6 +209,7 @@ class HESupportViewModel @Inject constructor( } } + @Suppress("ReturnCount") private fun Uri.getFileExtension(): String { // First, try to get extension from MIME type val mimeType = application.contentResolver.getType(this) 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 b9d6665f2200..0f099e12d4fa 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 @@ -45,13 +45,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage import org.wordpress.android.R +import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.ui.compose.theme.AppThemeM3 -interface AttachmentActionsListener { - fun onAddImageClick() - fun onRemoveImage(uri: Uri) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun TicketMainContentView( @@ -61,10 +57,7 @@ fun TicketMainContentView( onIncludeAppLogsChanged: (Boolean) -> Unit, enabled: Boolean = true, attachments: List = emptyList(), - attachmentActionsListener: AttachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() {} - override fun onRemoveImage(uri: Uri) {} - } + attachmentActionsListener: AttachmentActionsListener ) { Column( modifier = Modifier @@ -281,6 +274,7 @@ private fun ImagePreviewItem( @Preview(showBackground = true, name = "HE main ticket content") +@Suppress("EmptyFunctionBlock") @Composable private fun TicketMainContentViewPreview() { AppThemeM3(isDarkTheme = false) { @@ -288,12 +282,17 @@ private fun TicketMainContentViewPreview() { messageText = "", includeAppLogs = false, onMessageChanged = { }, - onIncludeAppLogsChanged = { } + onIncludeAppLogsChanged = { }, + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } @Preview(showBackground = true, name = "HE main ticket content - Dark", uiMode = UI_MODE_NIGHT_YES) +@Suppress("EmptyFunctionBlock") @Composable private fun TicketMainContentViewPreviewDark() { AppThemeM3(isDarkTheme = true) { @@ -301,7 +300,11 @@ private fun TicketMainContentViewPreviewDark() { messageText = "", includeAppLogs = false, onMessageChanged = { }, - onIncludeAppLogsChanged = { } + onIncludeAppLogsChanged = { }, + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/util/AttachmentActionsListener.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/AttachmentActionsListener.kt new file mode 100644 index 000000000000..6aa5717af853 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/AttachmentActionsListener.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.support.he.util + +import android.net.Uri + +interface AttachmentActionsListener { + fun onAddImageClick() + fun onRemoveImage(uri: Uri) +} From 9f5ec65e92ab422ac6106537079300fffb9e5868 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 17:34:01 +0100 Subject: [PATCH 133/153] Update RS version and some fixes --- .../support/he/ui/HESupportActivity.kt | 19 ++++++++++++++----- .../support/he/ui/HESupportViewModel.kt | 19 +++++++++++++++---- gradle/libs.versions.toml | 3 +-- 3 files changed, 30 insertions(+), 11 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 3380dd24ab5d..c2ca96d43de7 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 @@ -29,10 +29,12 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel.ErrorType import org.wordpress.android.ui.photopicker.MediaPickerConstants import org.wordpress.android.ui.reader.ReaderFileDownloadManager import org.wordpress.android.ui.mediapicker.MediaPickerSetup import org.wordpress.android.ui.mediapicker.MediaType +import org.wordpress.android.util.AppLog import javax.inject.Inject @AndroidEntryPoint @@ -44,15 +46,22 @@ class HESupportActivity : AppCompatActivity() { private lateinit var composeView: ComposeView private lateinit var navController: NavHostController + @Suppress("TooGenericExceptionCaught") private val photoPickerLauncher = registerForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult() ) { result -> - if (result.resultCode == RESULT_OK && result.data != null) { - val uris = result.data?.getStringArrayExtra(MediaPickerConstants.EXTRA_MEDIA_URIS) - uris?.let { uriStrings -> - val newUris = uriStrings.map { it.toUri() } - viewModel.addAttachments(newUris) + try { + if (result.resultCode == RESULT_OK && result.data != null) { + val uris = result.data?.getStringArrayExtra(MediaPickerConstants.EXTRA_MEDIA_URIS) + uris?.let { uriStrings -> + val newUris = uriStrings.map { it.toUri() } + viewModel.addAttachments(newUris) + } } + } catch (e: Exception) { + viewModel.notifyGeneralError() + appLogWrapper.e( + AppLog.T.SUPPORT, "Error getting attachments to add: ${e.stackTraceToString()}") } } 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 e4e9d2789ef0..96d1b928c4f3 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 @@ -95,7 +95,10 @@ class HESupportViewModel @Inject constructor( _isSendingMessage.value = false } catch (e: Exception) { _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error creating HE conversation") + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error creating HE conversation ${e.stackTraceToString()}" + ) } } } @@ -145,8 +148,10 @@ class HESupportViewModel @Inject constructor( _isSendingMessage.value = false } catch (e: Exception) { _errorMessage.value = ErrorType.GENERAL - appLogWrapper.e(AppLog.T.SUPPORT, "Error adding message to HE conversation: " + - e.stackTraceToString()) + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error adding message to HE conversation: ${e.stackTraceToString()}" + ) } } } @@ -167,6 +172,10 @@ class HESupportViewModel @Inject constructor( _attachments.value = emptyList() } + fun notifyGeneralError() { + _errorMessage.value = ErrorType.GENERAL + } + @Suppress("TooGenericExceptionCaught") private suspend fun copyUrisToTempFiles(uris: List): List = withContext(ioDispatcher) { uris.map{ it.toTempFile() } @@ -175,11 +184,13 @@ class HESupportViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") private suspend fun removeTempFiles(files: List) = withContext(ioDispatcher) { try { + var removed = files.isEmpty() // If empty, count them as removed files.forEach { file -> if (file.exists()) { - file.delete() + removed = removed && file.delete() } } + removed } catch (e: Exception) { appLogWrapper.e(AppLog.T.SUPPORT, "Error removing attachment temp files temp files: " + e.stackTraceToString()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cd9309fd9f6..2915bc60c7a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,8 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -## TODO: Use trunk commit when merge -wordpress-rs = '995-5b34ff29422a4db847044c72bd06672be922af22' +wordpress-rs = 'trunk-fb107b497caaf2b1f4ffcf9f487784792561a645' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1' From ce18ff0d1f7bca0cadd4e7e612d2c104e9eeebab Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 18:32:05 +0100 Subject: [PATCH 134/153] Extracting temp attachment utils --- .../support/he/ui/HESupportViewModel.kt | 82 ++--------------- .../support/he/util/TempAttachmentsUtil.kt | 87 +++++++++++++++++++ 2 files changed, 93 insertions(+), 76 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt 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 96d1b928c4f3..c7a906fc1c72 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 android.app.Application import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -9,7 +8,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD @@ -17,17 +15,17 @@ 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 +import org.wordpress.android.support.he.util.TempAttachmentsUtil import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper -import java.io.File import javax.inject.Inject import javax.inject.Named @HiltViewModel class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, - private val application: Application, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, + private val tempAttachmentsUtil: TempAttachmentsUtil, accountStore: AccountStore, appLogWrapper: AppLogWrapper, networkUtilsWrapper: NetworkUtilsWrapper, @@ -63,7 +61,7 @@ class HESupportViewModel @Inject constructor( try { _isSendingMessage.value = true - val files = copyUrisToTempFiles(_attachments.value) + val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) when (val result = heSupportRepository.createConversation( subject = subject, @@ -91,7 +89,7 @@ class HESupportViewModel @Inject constructor( } } - removeTempFiles(files) + tempAttachmentsUtil.removeTempFiles(files) _isSendingMessage.value = false } catch (e: Exception) { _errorMessage.value = ErrorType.GENERAL @@ -117,7 +115,7 @@ class HESupportViewModel @Inject constructor( } _isSendingMessage.value = true - val files = copyUrisToTempFiles(_attachments.value) + val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) when (val result = heSupportRepository.addMessageToConversation( conversationId = selectedConversation.id, @@ -144,7 +142,7 @@ class HESupportViewModel @Inject constructor( } } - removeTempFiles(files) + tempAttachmentsUtil.removeTempFiles(files) _isSendingMessage.value = false } catch (e: Exception) { _errorMessage.value = ErrorType.GENERAL @@ -175,72 +173,4 @@ class HESupportViewModel @Inject constructor( fun notifyGeneralError() { _errorMessage.value = ErrorType.GENERAL } - - @Suppress("TooGenericExceptionCaught") - private suspend fun copyUrisToTempFiles(uris: List): List = withContext(ioDispatcher) { - uris.map{ it.toTempFile() } - } - - @Suppress("TooGenericExceptionCaught") - private suspend fun removeTempFiles(files: List) = withContext(ioDispatcher) { - try { - var removed = files.isEmpty() // If empty, count them as removed - files.forEach { file -> - if (file.exists()) { - removed = removed && file.delete() - } - } - removed - } catch (e: Exception) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error removing attachment temp files temp files: " + - e.stackTraceToString()) - } - } - - @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") - private suspend fun Uri.toTempFile(): File = withContext(ioDispatcher) { - try { - val inputStream = application.contentResolver.openInputStream(this@toTempFile) - ?: throw Exception("Failed to open input stream for attachment") - - // Get file extension from MIME type or URI - val extension = getFileExtension() - val fileName = "support_attachment_${System.currentTimeMillis()}.$extension" - val tempFile = File(application.cacheDir, fileName) - - tempFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - inputStream.close() - - tempFile - } catch (e: Exception) { - appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URI to temp file: ${e.stackTraceToString()}") - throw e - } - } - - @Suppress("ReturnCount") - private fun Uri.getFileExtension(): String { - // First, try to get extension from MIME type - val mimeType = application.contentResolver.getType(this) - mimeType?.let { type -> - val extension = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(type) - if (!extension.isNullOrEmpty()) { - return extension - } - } - - // Fallback: try to extract extension from the URI path - val path = this.path - path?.let { - val lastDotIndex = it.lastIndexOf('.') - if (lastDotIndex > 0 && lastDotIndex < it.length - 1) { - return it.substring(lastDotIndex + 1) - } - } - - // Default to jpg if we can't determine the extension - return "jpg" - } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt new file mode 100644 index 000000000000..9278d1d89412 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt @@ -0,0 +1,87 @@ +package org.wordpress.android.support.he.util + +import android.app.Application +import android.net.Uri +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.util.AppLog +import java.io.File +import javax.inject.Inject +import javax.inject.Named +import kotlin.collections.forEach + +class TempAttachmentsUtil @Inject constructor( + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, + private val appLogWrapper: AppLogWrapper, + private val application: Application, +) { + @Suppress("TooGenericExceptionCaught") + suspend fun createTempFilesFrom(uris: List): List = withContext(ioDispatcher) { + uris.map{ it.toTempFile() } + } + + @Suppress("TooGenericExceptionCaught") + suspend fun removeTempFiles(files: List) = withContext(ioDispatcher) { + try { + var removed = files.isEmpty() // If empty, count them as removed + files.forEach { file -> + if (file.exists()) { + removed = removed && file.delete() + } + } + removed + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error removing attachment temp files temp files: " + + e.stackTraceToString()) + } + } + + @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") + private suspend fun Uri.toTempFile(): File = withContext(ioDispatcher) { + try { + val inputStream = application.contentResolver.openInputStream(this@toTempFile) + ?: throw Exception("Failed to open input stream for attachment") + + // Get file extension from MIME type or URI + val extension = getFileExtension() + val fileName = "support_attachment_${System.currentTimeMillis()}.$extension" + val tempFile = File(application.cacheDir, fileName) + + tempFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + inputStream.close() + + tempFile + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.SUPPORT, "Error copying URI to temp file: ${e.stackTraceToString()}") + throw e + } + } + + @Suppress("ReturnCount") + private fun Uri.getFileExtension(): String { + // First, try to get extension from MIME type + val mimeType = application.contentResolver.getType(this) + mimeType?.let { type -> + val extension = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(type) + if (!extension.isNullOrEmpty()) { + return extension + } + } + + // Fallback: try to extract extension from the URI path + val path = this.path + path?.let { + val lastDotIndex = it.lastIndexOf('.') + if (lastDotIndex > 0 && lastDotIndex < it.length - 1) { + return it.substring(lastDotIndex + 1) + } + } + + // Default to jpg if we can't determine the extension + return "jpg" + } +} From 9903bfb3147360e1999b835f9854296a98854c73 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 18:36:53 +0100 Subject: [PATCH 135/153] Adding new tests --- .../he/repository/HESupportRepositoryTest.kt | 3 +- .../support/he/ui/HESupportViewModelTest.kt | 366 +++++++++++++++++- 2 files changed, 347 insertions(+), 22 deletions(-) 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 ffafaf9cbbdc..3b99568e1d1a 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 @@ -372,6 +372,7 @@ class HESupportRepositoryTest : BaseUnitTest() { 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.author is SupportMessageAuthor.User, + attachments = emptyList() ) } 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 acbe3ef97efc..01575e0371a0 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,13 +1,17 @@ package org.wordpress.android.support.he.ui +import android.net.Uri import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher 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.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest @@ -19,6 +23,7 @@ import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository +import org.wordpress.android.support.he.util.TempAttachmentsUtil import org.wordpress.android.util.NetworkUtilsWrapper import java.util.Date @@ -36,6 +41,9 @@ class HESupportViewModelTest : BaseUnitTest() { @Mock private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + @Mock + private lateinit var tempAttachmentsUtil: TempAttachmentsUtil + private lateinit var viewModel: HESupportViewModel private val testAccessToken = "test_access_token" @@ -57,10 +65,16 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + runBlocking { + whenever(tempAttachmentsUtil.createTempFilesFrom(any())).thenReturn(emptyList()) + whenever(tempAttachmentsUtil.removeTempFiles(any())).thenReturn(Unit) + } viewModel = HESupportViewModel( - accountStore = accountStore, heSupportRepository = heSupportRepository, + ioDispatcher = UnconfinedTestDispatcher(), + tempAttachmentsUtil = tempAttachmentsUtil, + accountStore = accountStore, appLogWrapper = appLogWrapper, networkUtilsWrapper = networkUtilsWrapper, ) @@ -121,7 +135,6 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = emptyList() ) advanceUntilIdle() @@ -146,7 +159,6 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = emptyList() ) advanceUntilIdle() @@ -167,7 +179,6 @@ class HESupportViewModelTest : BaseUnitTest() { subject = "Test Subject", message = "Test Message", tags = listOf("tag1"), - attachments = emptyList() ) advanceUntilIdle() @@ -184,8 +195,7 @@ class HESupportViewModelTest : BaseUnitTest() { viewModel.onSendNewConversation( subject = "Test Subject", message = "Test Message", - tags = emptyList(), - attachments = emptyList() + tags = emptyList() ) advanceUntilIdle() @@ -214,8 +224,7 @@ class HESupportViewModelTest : BaseUnitTest() { @Test fun `onAddMessageToConversation does nothing when no conversation is selected`() = test { viewModel.onAddMessageToConversation( - message = "Test message", - attachments = emptyList() + message = "Test message" ) advanceUntilIdle() @@ -233,22 +242,21 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(heSupportRepository.addMessageToConversation( conversationId = 1L, message = "Test message", - attachments = listOf("attachment1") + attachments = emptyList() )).thenReturn(CreateConversationResult.Success(updatedConversation)) viewModel.onConversationClick(existingConversation) advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message", - attachments = listOf("attachment1") + message = "Test message" ) advanceUntilIdle() verify(heSupportRepository).addMessageToConversation( conversationId = 1L, message = "Test message", - attachments = listOf("attachment1") + attachments = emptyList() ) } @@ -269,8 +277,7 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message", - attachments = emptyList() + message = "Test message" ) advanceUntilIdle() @@ -291,8 +298,7 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message", - attachments = emptyList() + message = "Test message" ) advanceUntilIdle() @@ -314,8 +320,7 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message", - attachments = emptyList() + message = "Test message" ) advanceUntilIdle() @@ -335,8 +340,7 @@ class HESupportViewModelTest : BaseUnitTest() { advanceUntilIdle() viewModel.onAddMessageToConversation( - message = "Test message", - attachments = emptyList() + message = "Test message" ) advanceUntilIdle() @@ -345,6 +349,325 @@ class HESupportViewModelTest : BaseUnitTest() { // endregion + // region Attachment management tests + + @Test + fun `addAttachments adds URIs to attachments list`() { + val uri1 = mock() + val uri2 = mock() + + viewModel.addAttachments(listOf(uri1, uri2)) + + assertThat(viewModel.attachments.value).containsExactly(uri1, uri2) + } + + @Test + fun `addAttachments appends to existing attachments`() { + val uri1 = mock() + val uri2 = mock() + val uri3 = mock() + + viewModel.addAttachments(listOf(uri1)) + viewModel.addAttachments(listOf(uri2, uri3)) + + assertThat(viewModel.attachments.value).containsExactly(uri1, uri2, uri3) + } + + @Test + fun `removeAttachment removes specific URI from attachments list`() { + val uri1 = mock() + val uri2 = mock() + val uri3 = mock() + + viewModel.addAttachments(listOf(uri1, uri2, uri3)) + viewModel.removeAttachment(uri2) + + assertThat(viewModel.attachments.value).containsExactly(uri1, uri3) + } + + @Test + fun `removeAttachment does nothing when URI not in list`() { + val uri1 = mock() + val uri2 = mock() + val uri3 = mock() + + viewModel.addAttachments(listOf(uri1, uri2)) + viewModel.removeAttachment(uri3) + + assertThat(viewModel.attachments.value).containsExactly(uri1, uri2) + } + + @Test + fun `clearAttachments removes all attachments`() { + val uri1 = mock() + val uri2 = mock() + + viewModel.addAttachments(listOf(uri1, uri2)) + viewModel.clearAttachments() + + assertThat(viewModel.attachments.value).isEmpty() + } + + @Test + fun `attachments list is empty initially`() { + assertThat(viewModel.attachments.value).isEmpty() + } + + // endregion + + // region Attachment integration tests + + @Test + fun `onSendNewConversation sends attachments to repository`() = test { + val uri1 = mock() + val uri2 = mock() + val tempFile1 = java.io.File("/tmp/file1.jpg") + val tempFile2 = java.io.File("/tmp/file2.jpg") + val newConversation = createTestConversation(1) + + whenever(tempAttachmentsUtil.createTempFilesFrom(listOf(uri1, uri2))) + .thenReturn(listOf(tempFile1, tempFile2)) + whenever(heSupportRepository.createConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = listOf(tempFile1.path, tempFile2.path) + )).thenReturn(CreateConversationResult.Success(newConversation)) + + viewModel.addAttachments(listOf(uri1, uri2)) + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + ) + advanceUntilIdle() + + verify(tempAttachmentsUtil).createTempFilesFrom(listOf(uri1, uri2)) + verify(heSupportRepository).createConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + attachments = listOf(tempFile1.path, tempFile2.path) + ) + verify(tempAttachmentsUtil).removeTempFiles(listOf(tempFile1, tempFile2)) + } + + @Test + fun `onSendNewConversation clears attachments after success`() = test { + val uri1 = mock() + val newConversation = createTestConversation(1) + + whenever(heSupportRepository.createConversation( + any(), any(), any(), any() + )).thenReturn(CreateConversationResult.Success(newConversation)) + + viewModel.addAttachments(listOf(uri1)) + assertThat(viewModel.attachments.value).containsExactly(uri1) + + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + ) + advanceUntilIdle() + + assertThat(viewModel.attachments.value).isEmpty() + } + + @Test + fun `onSendNewConversation does not clear attachments on error`() = test { + val uri1 = mock() + + whenever(heSupportRepository.createConversation( + any(), any(), any(), any() + )).thenReturn(CreateConversationResult.Error.GeneralError) + + viewModel.addAttachments(listOf(uri1)) + + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + ) + advanceUntilIdle() + + assertThat(viewModel.attachments.value).containsExactly(uri1) + } + + @Test + fun `onSendNewConversation cleans up temp files even on error`() = test { + val uri1 = mock() + val tempFile1 = java.io.File("/tmp/file1.jpg") + + whenever(tempAttachmentsUtil.createTempFilesFrom(listOf(uri1))) + .thenReturn(listOf(tempFile1)) + whenever(heSupportRepository.createConversation( + any(), any(), any(), any() + )).thenReturn(CreateConversationResult.Error.GeneralError) + + viewModel.addAttachments(listOf(uri1)) + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + ) + advanceUntilIdle() + + verify(tempAttachmentsUtil).removeTempFiles(listOf(tempFile1)) + } + + @Test + fun `onAddMessageToConversation sends attachments to repository`() = test { + val uri1 = mock() + val tempFile1 = java.io.File("/tmp/file1.jpg") + val existingConversation = createTestConversation(1) + val updatedConversation = createTestConversation(1).copy( + messages = listOf(createTestMessage(1, "New message", true)) + ) + + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(tempAttachmentsUtil.createTempFilesFrom(listOf(uri1))) + .thenReturn(listOf(tempFile1)) + whenever(heSupportRepository.addMessageToConversation( + conversationId = 1L, + message = "Test message", + attachments = listOf(tempFile1.path) + )).thenReturn(CreateConversationResult.Success(updatedConversation)) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.addAttachments(listOf(uri1)) + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + verify(tempAttachmentsUtil).createTempFilesFrom(listOf(uri1)) + verify(heSupportRepository).addMessageToConversation( + conversationId = 1L, + message = "Test message", + attachments = listOf(tempFile1.path) + ) + verify(tempAttachmentsUtil).removeTempFiles(listOf(tempFile1)) + } + + @Test + fun `onAddMessageToConversation clears attachments after success`() = test { + val uri1 = mock() + val existingConversation = createTestConversation(1) + val updatedConversation = createTestConversation(1) + + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(heSupportRepository.addMessageToConversation( + any(), any(), any() + )).thenReturn(CreateConversationResult.Success(updatedConversation)) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.addAttachments(listOf(uri1)) + assertThat(viewModel.attachments.value).containsExactly(uri1) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + assertThat(viewModel.attachments.value).isEmpty() + } + + @Test + fun `onAddMessageToConversation does not clear attachments on error`() = test { + val uri1 = mock() + 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.addAttachments(listOf(uri1)) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + assertThat(viewModel.attachments.value).containsExactly(uri1) + } + + @Test + fun `onAddMessageToConversation cleans up temp files even on error`() = test { + val uri1 = mock() + val tempFile1 = java.io.File("/tmp/file1.jpg") + val existingConversation = createTestConversation(1) + + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(tempAttachmentsUtil.createTempFilesFrom(listOf(uri1))) + .thenReturn(listOf(tempFile1)) + whenever(heSupportRepository.addMessageToConversation( + any(), any(), any() + )).thenReturn(CreateConversationResult.Error.GeneralError) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.addAttachments(listOf(uri1)) + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + verify(tempAttachmentsUtil).removeTempFiles(listOf(tempFile1)) + } + + @Test + fun `onSendNewConversation handles exception during temp file creation`() = test { + val uri1 = mock() + + whenever(tempAttachmentsUtil.createTempFilesFrom(listOf(uri1))) + .thenThrow(RuntimeException("File creation failed")) + + viewModel.addAttachments(listOf(uri1)) + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1"), + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `onAddMessageToConversation handles exception during temp file creation`() = test { + val uri1 = mock() + val existingConversation = createTestConversation(1) + + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + whenever(tempAttachmentsUtil.createTempFilesFrom(listOf(uri1))) + .thenThrow(RuntimeException("File creation failed")) + + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + viewModel.addAttachments(listOf(uri1)) + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), any()) + } + + // endregion + // Helper functions private fun createTestConversation( id: Long, @@ -371,7 +694,8 @@ class HESupportViewModelTest : BaseUnitTest() { formattedText = AnnotatedString(text), createdAt = Date(), authorName = if (authorIsUser) "User" else "Support", - authorIsUser = authorIsUser + authorIsUser = authorIsUser, + attachments = emptyList() ) } } From 9ac2bd9ba3630c7aabbd0a661f0bfb16ddc3dccd Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 18:50:23 +0100 Subject: [PATCH 136/153] Some refactoring --- .../he/ui/HEConversationDetailScreen.kt | 31 +++-- .../he/ui/HEConversationReplyBottomSheet.kt | 8 +- .../support/he/ui/HENewTicketScreen.kt | 32 +++-- .../support/he/ui/HESupportActivity.kt | 111 ++++++++++-------- 4 files changed, 106 insertions(+), 76 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 b69c2fec5532..2690fab20957 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,6 +59,7 @@ 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.AttachmentActionsListener import org.wordpress.android.support.he.util.generateSampleHESupportConversations import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons @@ -75,9 +76,8 @@ fun HEConversationDetailScreen( onBackClick: () -> Unit, onSendMessage: (message: String, includeAppLogs: Boolean) -> Unit, onClearMessageSendResult: () -> Unit = {}, - onAddImageClick: () -> Unit = {}, attachments: List = emptyList(), - onRemoveImage: (Uri) -> Unit = {}, + attachmentActionsListener: AttachmentActionsListener, onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} ) { val listState = rememberLazyListState() @@ -193,9 +193,8 @@ fun HEConversationDetailScreen( draftIncludeAppLogs = false onClearMessageSendResult() }, - onAddImageClick = onAddImageClick, attachments = attachments, - onRemoveImage = onRemoveImage + attachmentActionsListener = attachmentActionsListener ) } @@ -493,7 +492,11 @@ private fun HEConversationDetailScreenPreview() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + onSendMessage = { _, _ -> }, + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } @@ -509,7 +512,11 @@ private fun HEConversationDetailScreenPreviewDark() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + onSendMessage = { _, _ -> }, + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } @@ -525,7 +532,11 @@ private fun HEConversationDetailScreenWordPressPreview() { snackbarHostState = snackbarHostState, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + onSendMessage = { _, _ -> }, + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } @@ -542,7 +553,11 @@ private fun HEConversationDetailScreenPreviewWordPressDark() { isLoading = true, conversation = sampleConversation, onBackClick = { }, - onSendMessage = { _, _ -> } + onSendMessage = { _, _ -> }, + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index 80e4bd64205e..15e0c1cfb6ea 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -43,9 +43,8 @@ fun HEConversationReplyBottomSheet( onDismiss: (currentMessage: String, currentIncludeAppLogs: Boolean) -> Unit, onSend: (String, Boolean) -> Unit, onMessageSentSuccessfully: () -> Unit, - onAddImageClick: () -> Unit = {}, attachments: List = emptyList(), - onRemoveImage: (Uri) -> Unit = {} + attachmentActionsListener: AttachmentActionsListener ) { var messageText by remember { mutableStateOf(initialMessageText) } var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } @@ -131,10 +130,7 @@ fun HEConversationReplyBottomSheet( onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, enabled = !isSending, attachments = attachments, - attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() = onAddImageClick() - override fun onRemoveImage(uri: Uri) = onRemoveImage(uri) - } + attachmentActionsListener = attachmentActionsListener ) } } 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 4841043ddfe1..27245019e66f 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 @@ -74,9 +74,8 @@ fun HENewTicketScreen( ) -> Unit, userInfo: UserInfo, isSendingNewConversation: Boolean = false, - onAddImageClick: () -> Unit = { }, attachments: List = emptyList(), - onRemoveImage: (Uri) -> Unit = { } + attachmentActionsListener: AttachmentActionsListener, ) { var selectedCategory by remember { mutableStateOf(null) } var subject by remember { mutableStateOf("") } @@ -199,10 +198,7 @@ fun HENewTicketScreen( onMessageChanged = { message -> messageText = message }, onIncludeAppLogsChanged = { checked -> includeAppLogs = checked }, attachments = attachments, - attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() = onAddImageClick() - override fun onRemoveImage(uri: Uri) = onRemoveImage(uri) - } + attachmentActionsListener = attachmentActionsListener ) Spacer(modifier = Modifier.height(32.dp)) @@ -428,7 +424,11 @@ private fun HENewTicketScreenPreview() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _-> }, - userInfo = UserInfo("Test user", "test.user@automattic.com", null) + userInfo = UserInfo("Test user", "test.user@automattic.com", null), + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } @@ -442,7 +442,11 @@ private fun HENewTicketScreenPreviewDark() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, - userInfo = UserInfo("Test user", "test.user@automattic.com", null) + userInfo = UserInfo("Test user", "test.user@automattic.com", null), + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } @@ -456,7 +460,11 @@ private fun HENewTicketScreenWordPressPreview() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, - userInfo = UserInfo("Test user", "test.user@automattic.com", null) + userInfo = UserInfo("Test user", "test.user@automattic.com", null), + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } @@ -470,7 +478,11 @@ private fun HENewTicketScreenPreviewWordPressDark() { snackbarHostState = snackbarHostState, onBackClick = { }, onSubmit = { _, _, _, _ -> }, - userInfo = UserInfo("Test user", "test.user@automattic.com", null) + userInfo = UserInfo("Test user", "test.user@automattic.com", null), + attachmentActionsListener = object : AttachmentActionsListener { + override fun onAddImageClick() { } + override fun onRemoveImage(uri: Uri) { } + } ) } } 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 c2ca96d43de7..8059dd4f280a 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 @@ -2,6 +2,7 @@ package org.wordpress.android.support.he.ui import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import androidx.core.net.toUri import android.os.Bundle @@ -185,32 +186,35 @@ class HESupportActivity : AppCompatActivity() { ) }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, - onAddImageClick = { - val mediaPickerSetup = MediaPickerSetup( - primaryDataSource = MediaPickerSetup.DataSource.DEVICE, - availableDataSources = setOf(), - canMultiselect = true, - requiresPhotosVideosPermissions = true, - requiresMusicAudioPermissions = false, - allowedTypes = setOf(MediaType.IMAGE), - cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, - systemPickerEnabled = true, - editingEnabled = true, - queueResults = false, - defaultSearchView = false, - title = R.string.photo_picker_title - ) - val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( - this@HESupportActivity, - mediaPickerSetup, - null, - null - ) - photoPickerLauncher.launch(intent) - }, attachments = attachments, - onRemoveImage = { imageuri -> - viewModel.removeAttachment(imageuri) + attachmentActionsListener = object : org.wordpress.android.support.he.util.AttachmentActionsListener { + override fun onAddImageClick() { + val mediaPickerSetup = MediaPickerSetup( + primaryDataSource = MediaPickerSetup.DataSource.DEVICE, + availableDataSources = setOf(), + canMultiselect = true, + requiresPhotosVideosPermissions = true, + requiresMusicAudioPermissions = false, + allowedTypes = setOf(MediaType.IMAGE), + cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, + systemPickerEnabled = true, + editingEnabled = true, + queueResults = false, + defaultSearchView = false, + title = R.string.photo_picker_title + ) + val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( + this@HESupportActivity, + mediaPickerSetup, + null, + null + ) + photoPickerLauncher.launch(intent) + } + + override fun onRemoveImage(uri: Uri) { + viewModel.removeAttachment(uri) + } }, onDownloadAttachment = { attachment -> // Show loading snackbar @@ -254,34 +258,37 @@ class HESupportActivity : AppCompatActivity() { }, userInfo = userInfo, isSendingNewConversation = isSendingNewConversation, - onAddImageClick = { - val mediaPickerSetup = MediaPickerSetup( - primaryDataSource = MediaPickerSetup.DataSource.DEVICE, - availableDataSources = setOf(), - canMultiselect = true, - requiresPhotosVideosPermissions = true, - requiresMusicAudioPermissions = false, - allowedTypes = setOf(MediaType.IMAGE), - cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, - systemPickerEnabled = true, - editingEnabled = true, - queueResults = false, - defaultSearchView = false, - title = R.string.photo_picker_title - ) - val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( - this@HESupportActivity, - mediaPickerSetup, - null, - null - ) - photoPickerLauncher.launch(intent) - }, attachments = attachments, - onRemoveImage = { imageUri -> - viewModel.removeAttachment(imageUri) - }, - ) + attachmentActionsListener = object : org.wordpress.android.support.he.util.AttachmentActionsListener { + override fun onAddImageClick() { + val mediaPickerSetup = MediaPickerSetup( + primaryDataSource = MediaPickerSetup.DataSource.DEVICE, + availableDataSources = setOf(), + canMultiselect = true, + requiresPhotosVideosPermissions = true, + requiresMusicAudioPermissions = false, + allowedTypes = setOf(MediaType.IMAGE), + cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, + systemPickerEnabled = true, + editingEnabled = true, + queueResults = false, + defaultSearchView = false, + title = R.string.photo_picker_title + ) + val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( + this@HESupportActivity, + mediaPickerSetup, + null, + null + ) + photoPickerLauncher.launch(intent) + } + + override fun onRemoveImage(uri: Uri) { + viewModel.removeAttachment(uri) + } + } + ) } } } From 6daee20ebd9cbde31fb2ce88e52d171d8c932608 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 19:58:03 +0100 Subject: [PATCH 137/153] Removing attachments preview to open a dedicated PR --- .../support/he/model/SupportMessage.kt | 10 - .../he/repository/HESupportRepository.kt | 19 -- .../he/ui/AttachmentFullscreenImagePreview.kt | 193 ------------------ .../he/ui/HEConversationDetailScreen.kt | 161 +++------------ .../support/he/ui/HENewTicketScreen.kt | 32 ++- .../support/he/ui/HESupportActivity.kt | 22 +- .../support/he/util/HEConversationUtils.kt | 34 --- .../he/repository/HESupportRepositoryTest.kt | 1 - .../support/he/ui/HESupportViewModelTest.kt | 1 - 9 files changed, 51 insertions(+), 422 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt 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 4b6b18145bcb..1b0e218889ee 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 @@ -12,14 +12,4 @@ data class SupportMessage( val createdAt: Date, val authorName: String, val authorIsUser: Boolean, - val attachments: List, ) - -data class SupportAttachment ( - val id: Long, - val filename: String, - val url: String, - val type: AttachmentType, -) - -enum class AttachmentType { Image, Video, Other } 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 e107d19c4300..8316bb37448e 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,8 +5,6 @@ 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.he.model.AttachmentType -import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString @@ -205,22 +203,5 @@ class HESupportRepository @Inject constructor( is SupportMessageAuthor.SupportAgent -> (author as SupportMessageAuthor.SupportAgent).v1.name }, authorIsUser = authorIsCurrentUser, - attachments = attachments.map { it.toSupportAttachment() } ) - - private fun uniffi.wp_api.SupportAttachment.toSupportAttachment(): SupportAttachment = - SupportAttachment( - id = id.toLong(), - filename = filename, - url = url, - type = determineAttachmentType(contentType) - ) - - private fun determineAttachmentType(contentType: String): AttachmentType { - return when { - contentType.startsWith("image/") -> AttachmentType.Image - contentType.startsWith("video/") -> AttachmentType.Video - else -> AttachmentType.Other - } - } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt deleted file mode 100644 index 5c98026e1cf4..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt +++ /dev/null @@ -1,193 +0,0 @@ -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.clickable -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -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.Close -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import org.wordpress.android.R -import org.wordpress.android.ui.compose.theme.AppThemeM3 - -@Composable -fun AttachmentFullscreenImagePreview( - imageUrl: String, - onDismiss: () -> Unit, - onDownload: () -> Unit = {} -) { - var scale by remember { mutableFloatStateOf(1f) } - var offsetX by remember { mutableFloatStateOf(0f) } - var offsetY by remember { mutableFloatStateOf(0f) } - - // Load semantics - val loadingImageDescription = stringResource(R.string.he_support_loading_image) - val attachmentImageDescription = stringResource(R.string.he_support_attachment_image) - val failedToLoadImageDescription = stringResource(R.string.he_support_failed_to_load_image) - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnBackPress = true, - dismissOnClickOutside = false - ) - ) { - Surface( - modifier = Modifier - .fillMaxSize() - .clickable(onClick = onDismiss), - color = Color.Black - ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator( - modifier = Modifier - .align(Alignment.Center) - .semantics { - contentDescription = loadingImageDescription - } - ) - // Zoomable image - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - SubcomposeAsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), - contentDescription = attachmentImageDescription, - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY - ) - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(1f, 5f) - if (scale > 1f) { - offsetX += pan.x - offsetY += pan.y - } else { - offsetX = 0f - offsetY = 0f - } - } - }, - contentScale = ContentScale.Fit, - error = { - Icon( - painter = painterResource(R.drawable.ic_image_white_24dp), - contentDescription = failedToLoadImageDescription, - tint = Color.White, - modifier = Modifier.size(48.dp) - ) - } - ) - } - - // Top bar with close button - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(16.dp) - .background( - color = Color.Black.copy(alpha = 0.5f), - shape = RoundedCornerShape(24.dp) - ) - .padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Download button - IconButton( - onClick = { - onDownload.invoke() - onDismiss.invoke() - } - ) { - Icon( - painter = painterResource(R.drawable.ic_get_app_white_24dp), - contentDescription = stringResource(R.string.he_support_download_image), - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } - - // Close button - IconButton( - onClick = onDismiss - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.close), - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } - } - } - } - } -} - -@Preview(showBackground = true, name = "Fullscreen Image Preview") -@Composable -private fun AttachmentFullscreenImagePreviewPreview() { - AppThemeM3(isDarkTheme = false) { - AttachmentFullscreenImagePreview( - imageUrl = "https://via.placeholder.com/800x600", - onDismiss = { }, - onDownload = { } - ) - } -} - -@Preview(showBackground = true, name = "Fullscreen Image Preview - Dark", uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun AttachmentFullscreenImagePreviewPreviewDark() { - AppThemeM3(isDarkTheme = true) { - AttachmentFullscreenImagePreview( - imageUrl = "https://via.placeholder.com/800x600", - onDismiss = { }, - onDownload = { } - ) - } -} 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 2690fab20957..c8ba35071330 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 @@ -3,7 +3,6 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,7 +13,6 @@ 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.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -38,12 +36,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import coil.compose.SubcomposeAsyncImage import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -54,7 +49,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import coil.request.ImageRequest import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.he.model.SupportConversation @@ -78,7 +72,6 @@ fun HEConversationDetailScreen( onClearMessageSendResult: () -> Unit = {}, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener, - onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -90,9 +83,6 @@ fun HEConversationDetailScreen( var draftMessageText by remember { mutableStateOf("") } var draftIncludeAppLogs by remember { mutableStateOf(false) } - // State for fullscreen image preview - var previewImageUrl by remember { mutableStateOf(null) } - // Scroll to bottom when conversation changes or new messages arrive LaunchedEffect(conversation.messages.size) { if (conversation.messages.isNotEmpty()) { @@ -149,8 +139,6 @@ fun HEConversationDetailScreen( MessageItem( message = message, timestamp = formatRelativeTime(message.createdAt, resources), - onPreviewImage = { imageUrl -> previewImageUrl = imageUrl }, - onDownloadAttachment = onDownloadAttachment ) } @@ -197,22 +185,6 @@ fun HEConversationDetailScreen( attachmentActionsListener = attachmentActionsListener ) } - - // Show fullscreen image preview when an image attachment is tapped - previewImageUrl?.let { imageUrl -> - // Find the attachment with this URL to get the filename for download - val attachment = conversation.messages - .flatMap { it.attachments } - .firstOrNull { it.url == imageUrl } - - AttachmentFullscreenImagePreview( - imageUrl = imageUrl, - onDismiss = { previewImageUrl = null }, - onDownload = { - attachment?.let { onDownloadAttachment(it) } - } - ) - } } @Composable @@ -288,8 +260,6 @@ private fun ConversationTitleCard(title: String) { private fun MessageItem( message: SupportMessage, timestamp: String, - onPreviewImage: (String) -> Unit, - onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -343,105 +313,6 @@ private fun MessageItem( color = MaterialTheme.colorScheme.onSurface ) ) - - // Display attachments if present - if (message.attachments.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - AttachmentsList( - attachments = message.attachments, - onPreviewImage = onPreviewImage, - onDownloadAttachment = onDownloadAttachment - ) - } - } - } -} - -@Composable -private fun AttachmentsList( - attachments: List, - onPreviewImage: (String) -> Unit, - onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit -) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - attachments.forEach { attachment -> - AttachmentItem( - attachment = attachment, - onClick = { - if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { - onPreviewImage(attachment.url) - } else { - onDownloadAttachment(attachment) - } - } - ) - } - } -} - -@Composable -private fun AttachmentItem( - attachment: org.wordpress.android.support.he.model.SupportAttachment, - onClick: () -> Unit -) { - val iconRes = when (attachment.type) { - org.wordpress.android.support.he.model.AttachmentType.Image -> R.drawable.ic_image_white_24dp - org.wordpress.android.support.he.model.AttachmentType.Video -> R.drawable.ic_video_camera_white_24dp - org.wordpress.android.support.he.model.AttachmentType.Other -> R.drawable.ic_pages_white_24dp - } - - Box( - modifier = Modifier - .size(120.dp) - .clickable(onClick = onClick) - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - shape = RoundedCornerShape(8.dp) - ), - contentAlignment = Alignment.Center - ) { - if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { - // Show image preview for image attachments - SubcomposeAsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(attachment.url) - .crossfade(true) - .build(), - contentDescription = attachment.filename, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - loading = { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp - ) - } - }, - error = { - // Show icon if image fails to load - Icon( - painter = painterResource(iconRes), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) - ) - } - ) - } else { - // Show icon for non-image attachments - Icon( - painter = painterResource(iconRes), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) - ) } } } @@ -494,8 +365,12 @@ private fun HEConversationDetailScreenPreview() { onBackClick = { }, onSendMessage = { _, _ -> }, attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } @@ -514,8 +389,12 @@ private fun HEConversationDetailScreenPreviewDark() { onBackClick = { }, onSendMessage = { _, _ -> }, attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } @@ -534,8 +413,12 @@ private fun HEConversationDetailScreenWordPressPreview() { onBackClick = { }, onSendMessage = { _, _ -> }, attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } @@ -555,8 +438,12 @@ private fun HEConversationDetailScreenPreviewWordPressDark() { onBackClick = { }, onSendMessage = { _, _ -> }, attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } 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 27245019e66f..413d2897ec0d 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 @@ -426,8 +426,12 @@ private fun HENewTicketScreenPreview() { onSubmit = { _, _, _, _-> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } @@ -444,8 +448,12 @@ private fun HENewTicketScreenPreviewDark() { onSubmit = { _, _, _, _ -> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } @@ -462,8 +470,12 @@ private fun HENewTicketScreenWordPressPreview() { onSubmit = { _, _, _, _ -> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } @@ -480,8 +492,12 @@ private fun HENewTicketScreenPreviewWordPressDark() { onSubmit = { _, _, _, _ -> }, userInfo = UserInfo("Test user", "test.user@automattic.com", null), attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { } - override fun onRemoveImage(uri: Uri) { } + override fun onAddImageClick() { + // stub + } + override fun onRemoveImage(uri: Uri) { + // stub + } } ) } 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 8059dd4f280a..cc2546f1ddcb 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,9 +30,8 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel -import org.wordpress.android.support.common.ui.ConversationsSupportViewModel.ErrorType +import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.ui.photopicker.MediaPickerConstants -import org.wordpress.android.ui.reader.ReaderFileDownloadManager import org.wordpress.android.ui.mediapicker.MediaPickerSetup import org.wordpress.android.ui.mediapicker.MediaType import org.wordpress.android.util.AppLog @@ -40,7 +39,6 @@ import javax.inject.Inject @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { - @Inject lateinit var fileDownloadManager: ReaderFileDownloadManager @Inject lateinit var appLogWrapper: AppLogWrapper private val viewModel by viewModels() @@ -187,7 +185,7 @@ class HESupportActivity : AppCompatActivity() { }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, attachments = attachments, - attachmentActionsListener = object : org.wordpress.android.support.he.util.AttachmentActionsListener { + attachmentActionsListener = object : AttachmentActionsListener { override fun onAddImageClick() { val mediaPickerSetup = MediaPickerSetup( primaryDataSource = MediaPickerSetup.DataSource.DEVICE, @@ -216,20 +214,6 @@ class HESupportActivity : AppCompatActivity() { viewModel.removeAttachment(uri) } }, - onDownloadAttachment = { attachment -> - // Show loading snackbar - scope.launch { - snackbarHostState.showSnackbar( - message = getString( - R.string.he_support_downloading_attachment, - attachment.filename - ), - duration = SnackbarDuration.Short - ) - } - // Start download with proper filename - fileDownloadManager.downloadFile(attachment.url, attachment.filename) - } ) } } @@ -259,7 +243,7 @@ class HESupportActivity : AppCompatActivity() { userInfo = userInfo, isSendingNewConversation = isSendingNewConversation, attachments = attachments, - attachmentActionsListener = object : org.wordpress.android.support.he.util.AttachmentActionsListener { + attachmentActionsListener = object : AttachmentActionsListener { override fun onAddImageClick() { val mediaPickerSetup = MediaPickerSetup( primaryDataSource = MediaPickerSetup.DataSource.DEVICE, 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 f662d8c0bf70..9e7916533103 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,8 +1,6 @@ package org.wordpress.android.support.he.util import androidx.compose.ui.text.AnnotatedString -import org.wordpress.android.support.he.model.AttachmentType -import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import java.util.Date @@ -30,20 +28,6 @@ fun generateSampleHESupportConversations(): List { createdAt = Date(oneHourAgo.time - 1800000), authorName = "You", authorIsUser = true, - attachments = listOf( - SupportAttachment( - id = 1, - filename = "screenshot.png", - url = "https://example.com/attachments/screenshot.png", - type = AttachmentType.Image - ), - SupportAttachment( - id = 2, - filename = "error-log.txt", - url = "https://example.com/attachments/error-log.txt", - type = AttachmentType.Other - ) - ) ), SupportMessage( id = 2, @@ -53,7 +37,6 @@ fun generateSampleHESupportConversations(): List { createdAt = Date(oneHourAgo.time - 900000), authorName = "Support Agent", authorIsUser = false, - attachments = emptyList() ), SupportMessage( id = 3, @@ -62,7 +45,6 @@ fun generateSampleHESupportConversations(): List { createdAt = oneHourAgo, authorName = "You", authorIsUser = true, - attachments = emptyList() ) ) ), @@ -81,7 +63,6 @@ fun generateSampleHESupportConversations(): List { createdAt = Date(twoDaysAgo.time - 3600000), authorName = "You", authorIsUser = true, - attachments = emptyList() ), SupportMessage( id = 5, @@ -90,7 +71,6 @@ fun generateSampleHESupportConversations(): List { createdAt = twoDaysAgo, authorName = "Support Agent", authorIsUser = false, - attachments = emptyList() ) ) ), @@ -109,20 +89,6 @@ fun generateSampleHESupportConversations(): List { createdAt = oneWeekAgo, authorName = "You", authorIsUser = true, - attachments = listOf( - SupportAttachment( - id = 3, - filename = "domain-settings.pdf", - url = "https://example.com/attachments/domain-settings.pdf", - type = AttachmentType.Other - ), - SupportAttachment( - id = 4, - filename = "setup-tutorial.mp4", - url = "https://example.com/attachments/setup-tutorial.mp4", - type = AttachmentType.Video - ) - ) ) ) ) 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 3b99568e1d1a..2c10b66848ba 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 @@ -373,6 +373,5 @@ class HESupportRepositoryTest : BaseUnitTest() { is SupportMessageAuthor.SupportAgent -> (this.author as SupportMessageAuthor.SupportAgent).v1.name }, authorIsUser = this.author is SupportMessageAuthor.User, - attachments = emptyList() ) } 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 01575e0371a0..64f31da75e71 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 @@ -695,7 +695,6 @@ class HESupportViewModelTest : BaseUnitTest() { createdAt = Date(), authorName = if (authorIsUser) "User" else "Support", authorIsUser = authorIsUser, - attachments = emptyList() ) } } From 3687944a50d3489efe1db162384f98c4ee2ef2db Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 20:05:38 +0100 Subject: [PATCH 138/153] Useless changes --- .../support/he/model/SupportMessage.kt | 2 +- .../he/repository/HESupportRepository.kt | 26 +++++++++---------- .../support/he/util/HEConversationUtils.kt | 12 ++++----- .../he/repository/HESupportRepositoryTest.kt | 2 +- .../support/he/ui/HESupportViewModelTest.kt | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) 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 1b0e218889ee..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 @@ -11,5 +11,5 @@ data class SupportMessage( val formattedText: AnnotatedString, val createdAt: Date, val authorName: String, - val authorIsUser: Boolean, + 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 8316bb37448e..999f3175d3dd 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 @@ -185,23 +185,23 @@ class HESupportRepository @Inject constructor( private fun uniffi.wp_api.SupportConversation.toSupportConversation(): SupportConversation = SupportConversation( - id = id.toLong(), - title = title, - description = description, - lastMessageSentAt = updatedAt, - messages = messages.map { it.toSupportMessage() } + 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 = id.toLong(), - rawText = content, - formattedText = markdownToAnnotatedString(content), - createdAt = createdAt, - authorName = when (author) { - is SupportMessageAuthor.User -> (author as SupportMessageAuthor.User).v1.displayName - is SupportMessageAuthor.SupportAgent -> (author as SupportMessageAuthor.SupportAgent).v1.name + id = this.id.toLong(), + rawText = this.content, + formattedText = markdownToAnnotatedString(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 = authorIsCurrentUser, + authorIsUser = this.authorIsCurrentUser, ) } 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 9e7916533103..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 @@ -27,7 +27,7 @@ fun generateSampleHESupportConversations(): List { "the past few days."), createdAt = Date(oneHourAgo.time - 1800000), authorName = "You", - authorIsUser = true, + authorIsUser = true ), SupportMessage( id = 2, @@ -36,7 +36,7 @@ fun generateSampleHESupportConversations(): List { "Can you share your site URL?"), createdAt = Date(oneHourAgo.time - 900000), authorName = "Support Agent", - authorIsUser = false, + authorIsUser = false ), SupportMessage( id = 3, @@ -44,7 +44,7 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("Sure, it's example.wordpress.com"), createdAt = oneHourAgo, authorName = "You", - authorIsUser = true, + authorIsUser = true ) ) ), @@ -62,7 +62,7 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I'm trying to install a new plugin but getting an error."), createdAt = Date(twoDaysAgo.time - 3600000), authorName = "You", - authorIsUser = true, + authorIsUser = true ), SupportMessage( id = 5, @@ -70,7 +70,7 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I can help with that! What's the error message you're seeing?"), createdAt = twoDaysAgo, authorName = "Support Agent", - authorIsUser = false, + authorIsUser = false ) ) ), @@ -88,7 +88,7 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I need help setting up my custom domain."), createdAt = oneWeekAgo, authorName = "You", - authorIsUser = true, + authorIsUser = true ) ) ) 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 2c10b66848ba..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 @@ -372,6 +372,6 @@ class HESupportRepositoryTest : BaseUnitTest() { 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.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 64f31da75e71..f369e47ebf5c 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 @@ -694,7 +694,7 @@ class HESupportViewModelTest : BaseUnitTest() { formattedText = AnnotatedString(text), createdAt = Date(), authorName = if (authorIsUser) "User" else "Support", - authorIsUser = authorIsUser, + authorIsUser = authorIsUser ) } } From 529123fde7102bb87d803cb74d065fab75a749d2 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 31 Oct 2025 20:07:33 +0100 Subject: [PATCH 139/153] Useless changes --- .../android/support/he/repository/HESupportRepository.kt | 2 +- .../android/support/he/ui/HEConversationDetailScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 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 999f3175d3dd..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 @@ -202,6 +202,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.authorIsCurrentUser, + authorIsUser = this.authorIsCurrentUser ) } 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 c8ba35071330..c96a0b6fee6b 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 @@ -259,7 +259,7 @@ private fun ConversationTitleCard(title: String) { @Composable private fun MessageItem( message: SupportMessage, - timestamp: String, + timestamp: String ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" From 602b014ce2d00238f7a4e7b83e48961182d4df0d Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 11:24:11 +0100 Subject: [PATCH 140/153] Minor refactor --- .../support/he/ui/HESupportActivity.kt | 92 +++++++------------ 1 file changed, 34 insertions(+), 58 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 cc2546f1ddcb..15145bdb03c3 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 @@ -185,35 +185,7 @@ class HESupportActivity : AppCompatActivity() { }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, attachments = attachments, - attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { - val mediaPickerSetup = MediaPickerSetup( - primaryDataSource = MediaPickerSetup.DataSource.DEVICE, - availableDataSources = setOf(), - canMultiselect = true, - requiresPhotosVideosPermissions = true, - requiresMusicAudioPermissions = false, - allowedTypes = setOf(MediaType.IMAGE), - cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, - systemPickerEnabled = true, - editingEnabled = true, - queueResults = false, - defaultSearchView = false, - title = R.string.photo_picker_title - ) - val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( - this@HESupportActivity, - mediaPickerSetup, - null, - null - ) - photoPickerLauncher.launch(intent) - } - - override fun onRemoveImage(uri: Uri) { - viewModel.removeAttachment(uri) - } - }, + attachmentActionsListener = createAttachmentActionListener() ) } } @@ -243,41 +215,45 @@ class HESupportActivity : AppCompatActivity() { userInfo = userInfo, isSendingNewConversation = isSendingNewConversation, attachments = attachments, - attachmentActionsListener = object : AttachmentActionsListener { - override fun onAddImageClick() { - val mediaPickerSetup = MediaPickerSetup( - primaryDataSource = MediaPickerSetup.DataSource.DEVICE, - availableDataSources = setOf(), - canMultiselect = true, - requiresPhotosVideosPermissions = true, - requiresMusicAudioPermissions = false, - allowedTypes = setOf(MediaType.IMAGE), - cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, - systemPickerEnabled = true, - editingEnabled = true, - queueResults = false, - defaultSearchView = false, - title = R.string.photo_picker_title - ) - val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( - this@HESupportActivity, - mediaPickerSetup, - null, - null - ) - photoPickerLauncher.launch(intent) - } - - override fun onRemoveImage(uri: Uri) { - viewModel.removeAttachment(uri) - } - } + attachmentActionsListener = createAttachmentActionListener() ) } } } } + private fun createAttachmentActionListener(): AttachmentActionsListener { + return object : AttachmentActionsListener { + override fun onAddImageClick() { + val mediaPickerSetup = MediaPickerSetup( + primaryDataSource = MediaPickerSetup.DataSource.DEVICE, + availableDataSources = setOf(), + canMultiselect = true, + requiresPhotosVideosPermissions = true, + requiresMusicAudioPermissions = false, + allowedTypes = setOf(MediaType.IMAGE, MediaType.VIDEO), + cameraSetup = MediaPickerSetup.CameraSetup.HIDDEN, + systemPickerEnabled = true, + editingEnabled = true, + queueResults = false, + defaultSearchView = false, + title = R.string.photo_picker_title + ) + val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( + this@HESupportActivity, + mediaPickerSetup, + null, + null + ) + photoPickerLauncher.launch(intent) + } + + override fun onRemoveImage(uri: Uri) { + viewModel.removeAttachment(uri) + } + } + } + companion object { @JvmStatic fun createIntent(context: Context): Intent = Intent(context, HESupportActivity::class.java) From 7890c15aca2a31e0d21cc2096e23f0f6af268244 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 11:29:34 +0100 Subject: [PATCH 141/153] Showing attachments previews --- .../support/he/model/SupportMessage.kt | 12 +- .../he/repository/HESupportRepository.kt | 45 ++-- .../he/ui/AttachmentFullscreenImagePreview.kt | 193 ++++++++++++++++++ .../he/ui/HEConversationDetailScreen.kt | 135 +++++++++++- .../support/he/ui/HESupportActivity.kt | 24 ++- .../support/he/util/HEConversationUtils.kt | 46 ++++- .../he/repository/HESupportRepositoryTest.kt | 3 +- .../support/he/ui/HESupportViewModelTest.kt | 3 +- 8 files changed, 434 insertions(+), 27 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt 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 ae76fdb21a42..4b6b18145bcb 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 @@ -11,5 +11,15 @@ data class SupportMessage( val formattedText: AnnotatedString, val createdAt: Date, val authorName: String, - val authorIsUser: Boolean + val authorIsUser: Boolean, + val attachments: List, ) + +data class SupportAttachment ( + val id: Long, + val filename: String, + val url: String, + val type: AttachmentType, +) + +enum class AttachmentType { Image, Video, Other } 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 be88432043f9..e107d19c4300 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,6 +5,8 @@ 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.he.model.AttachmentType +import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString @@ -185,23 +187,40 @@ class HESupportRepository @Inject constructor( 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() } + id = id.toLong(), + title = title, + description = description, + lastMessageSentAt = updatedAt, + messages = messages.map { it.toSupportMessage() } ) private fun uniffi.wp_api.SupportMessage.toSupportMessage(): SupportMessage = SupportMessage( - id = this.id.toLong(), - rawText = this.content, - formattedText = markdownToAnnotatedString(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 + id = id.toLong(), + rawText = content, + formattedText = markdownToAnnotatedString(content), + createdAt = createdAt, + authorName = when (author) { + is SupportMessageAuthor.User -> (author as SupportMessageAuthor.User).v1.displayName + is SupportMessageAuthor.SupportAgent -> (author as SupportMessageAuthor.SupportAgent).v1.name }, - authorIsUser = this.authorIsCurrentUser + authorIsUser = authorIsCurrentUser, + attachments = attachments.map { it.toSupportAttachment() } ) + + private fun uniffi.wp_api.SupportAttachment.toSupportAttachment(): SupportAttachment = + SupportAttachment( + id = id.toLong(), + filename = filename, + url = url, + type = determineAttachmentType(contentType) + ) + + private fun determineAttachmentType(contentType: String): AttachmentType { + return when { + contentType.startsWith("image/") -> AttachmentType.Image + contentType.startsWith("video/") -> AttachmentType.Video + else -> AttachmentType.Other + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt new file mode 100644 index 000000000000..5c98026e1cf4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt @@ -0,0 +1,193 @@ +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.clickable +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +@Composable +fun AttachmentFullscreenImagePreview( + imageUrl: String, + onDismiss: () -> Unit, + onDownload: () -> Unit = {} +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + // Load semantics + val loadingImageDescription = stringResource(R.string.he_support_loading_image) + val attachmentImageDescription = stringResource(R.string.he_support_attachment_image) + val failedToLoadImageDescription = stringResource(R.string.he_support_failed_to_load_image) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onDismiss), + color = Color.Black + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .semantics { + contentDescription = loadingImageDescription + } + ) + // Zoomable image + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = attachmentImageDescription, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } + } + }, + contentScale = ContentScale.Fit, + error = { + Icon( + painter = painterResource(R.drawable.ic_image_white_24dp), + contentDescription = failedToLoadImageDescription, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + } + ) + } + + // Top bar with close button + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(24.dp) + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Download button + IconButton( + onClick = { + onDownload.invoke() + onDismiss.invoke() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_get_app_white_24dp), + contentDescription = stringResource(R.string.he_support_download_image), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + + // Close button + IconButton( + onClick = onDismiss + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.close), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } +} + +@Preview(showBackground = true, name = "Fullscreen Image Preview") +@Composable +private fun AttachmentFullscreenImagePreviewPreview() { + AppThemeM3(isDarkTheme = false) { + AttachmentFullscreenImagePreview( + imageUrl = "https://via.placeholder.com/800x600", + onDismiss = { }, + onDownload = { } + ) + } +} + +@Preview(showBackground = true, name = "Fullscreen Image Preview - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AttachmentFullscreenImagePreviewPreviewDark() { + AppThemeM3(isDarkTheme = true) { + AttachmentFullscreenImagePreview( + imageUrl = "https://via.placeholder.com/800x600", + onDismiss = { }, + onDownload = { } + ) + } +} 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 c96a0b6fee6b..b27be0296be8 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 @@ -3,6 +3,7 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +14,7 @@ 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.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -36,9 +38,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import coil.compose.SubcomposeAsyncImage import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -49,6 +54,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.request.ImageRequest import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.he.model.SupportConversation @@ -72,6 +78,7 @@ fun HEConversationDetailScreen( onClearMessageSendResult: () -> Unit = {}, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener, + onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -83,6 +90,9 @@ fun HEConversationDetailScreen( var draftMessageText by remember { mutableStateOf("") } var draftIncludeAppLogs by remember { mutableStateOf(false) } + // State for fullscreen image preview + var previewImageUrl by remember { mutableStateOf(null) } + // Scroll to bottom when conversation changes or new messages arrive LaunchedEffect(conversation.messages.size) { if (conversation.messages.isNotEmpty()) { @@ -139,6 +149,8 @@ fun HEConversationDetailScreen( MessageItem( message = message, timestamp = formatRelativeTime(message.createdAt, resources), + onPreviewImage = { imageUrl -> previewImageUrl = imageUrl }, + onDownloadAttachment = onDownloadAttachment ) } @@ -185,6 +197,22 @@ fun HEConversationDetailScreen( attachmentActionsListener = attachmentActionsListener ) } + + // Show fullscreen image preview when an image attachment is tapped + previewImageUrl?.let { imageUrl -> + // Find the attachment with this URL to get the filename for download + val attachment = conversation.messages + .flatMap { it.attachments } + .firstOrNull { it.url == imageUrl } + + AttachmentFullscreenImagePreview( + imageUrl = imageUrl, + onDismiss = { previewImageUrl = null }, + onDownload = { + attachment?.let { onDownloadAttachment(it) } + } + ) + } } @Composable @@ -259,7 +287,9 @@ private fun ConversationTitleCard(title: String) { @Composable private fun MessageItem( message: SupportMessage, - timestamp: String + timestamp: String, + onPreviewImage: (String) -> Unit, + onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -313,6 +343,105 @@ private fun MessageItem( color = MaterialTheme.colorScheme.onSurface ) ) + + // Display attachments if present + if (message.attachments.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + AttachmentsList( + attachments = message.attachments, + onPreviewImage = onPreviewImage, + onDownloadAttachment = onDownloadAttachment + ) + } + } + } +} + +@Composable +private fun AttachmentsList( + attachments: List, + onPreviewImage: (String) -> Unit, + onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + attachments.forEach { attachment -> + AttachmentItem( + attachment = attachment, + onClick = { + if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { + onPreviewImage(attachment.url) + } else { + onDownloadAttachment(attachment) + } + } + ) + } + } +} + +@Composable +private fun AttachmentItem( + attachment: org.wordpress.android.support.he.model.SupportAttachment, + onClick: () -> Unit +) { + val iconRes = when (attachment.type) { + org.wordpress.android.support.he.model.AttachmentType.Image -> R.drawable.ic_image_white_24dp + org.wordpress.android.support.he.model.AttachmentType.Video -> R.drawable.ic_video_camera_white_24dp + org.wordpress.android.support.he.model.AttachmentType.Other -> R.drawable.ic_pages_white_24dp + } + + Box( + modifier = Modifier + .size(120.dp) + .clickable(onClick = onClick) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { + // Show image preview for image attachments + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(attachment.url) + .crossfade(true) + .build(), + contentDescription = attachment.filename, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + loading = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + }, + error = { + // Show icon if image fails to load + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + } + ) + } else { + // Show icon for non-image attachments + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) } } } @@ -410,7 +539,9 @@ private fun HEConversationDetailScreenWordPressPreview() { HEConversationDetailScreen( snackbarHostState = snackbarHostState, conversation = sampleConversation, - onBackClick = { }, + onBackClick = { + // stub + }, onSendMessage = { _, _ -> }, attachmentActionsListener = object : AttachmentActionsListener { override fun onAddImageClick() { 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 15145bdb03c3..f8da1ca00bad 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,8 +30,10 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel.ErrorType import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.ui.photopicker.MediaPickerConstants +import org.wordpress.android.ui.reader.ReaderFileDownloadManager import org.wordpress.android.ui.mediapicker.MediaPickerSetup import org.wordpress.android.ui.mediapicker.MediaType import org.wordpress.android.util.AppLog @@ -39,6 +41,7 @@ import javax.inject.Inject @AndroidEntryPoint class HESupportActivity : AppCompatActivity() { + @Inject lateinit var fileDownloadManager: ReaderFileDownloadManager @Inject lateinit var appLogWrapper: AppLogWrapper private val viewModel by viewModels() @@ -185,7 +188,21 @@ class HESupportActivity : AppCompatActivity() { }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, attachments = attachments, - attachmentActionsListener = createAttachmentActionListener() + attachmentActionsListener = AttachmentActionsListener(), + onDownloadAttachment = { attachment -> + // Show loading snackbar + scope.launch { + snackbarHostState.showSnackbar( + message = getString( + R.string.he_support_downloading_attachment, + attachment.filename + ), + duration = SnackbarDuration.Short + ) + } + // Start download with proper filename + fileDownloadManager.downloadFile(attachment.url, attachment.filename) + } ) } } @@ -215,14 +232,14 @@ class HESupportActivity : AppCompatActivity() { userInfo = userInfo, isSendingNewConversation = isSendingNewConversation, attachments = attachments, - attachmentActionsListener = createAttachmentActionListener() + attachmentActionsListener = AttachmentActionsListener() ) } } } } - private fun createAttachmentActionListener(): AttachmentActionsListener { + private fun AttachmentActionsListener(): AttachmentActionsListener { return object : AttachmentActionsListener { override fun onAddImageClick() { val mediaPickerSetup = MediaPickerSetup( @@ -254,6 +271,7 @@ class HESupportActivity : AppCompatActivity() { } } + 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/util/HEConversationUtils.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/HEConversationUtils.kt index 90963637c132..f662d8c0bf70 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,6 +1,8 @@ package org.wordpress.android.support.he.util import androidx.compose.ui.text.AnnotatedString +import org.wordpress.android.support.he.model.AttachmentType +import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import java.util.Date @@ -27,7 +29,21 @@ fun generateSampleHESupportConversations(): List { "the past few days."), createdAt = Date(oneHourAgo.time - 1800000), authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = listOf( + SupportAttachment( + id = 1, + filename = "screenshot.png", + url = "https://example.com/attachments/screenshot.png", + type = AttachmentType.Image + ), + SupportAttachment( + id = 2, + filename = "error-log.txt", + url = "https://example.com/attachments/error-log.txt", + type = AttachmentType.Other + ) + ) ), SupportMessage( id = 2, @@ -36,7 +52,8 @@ fun generateSampleHESupportConversations(): List { "Can you share your site URL?"), createdAt = Date(oneHourAgo.time - 900000), authorName = "Support Agent", - authorIsUser = false + authorIsUser = false, + attachments = emptyList() ), SupportMessage( id = 3, @@ -44,7 +61,8 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("Sure, it's example.wordpress.com"), createdAt = oneHourAgo, authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = emptyList() ) ) ), @@ -62,7 +80,8 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I'm trying to install a new plugin but getting an error."), createdAt = Date(twoDaysAgo.time - 3600000), authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = emptyList() ), SupportMessage( id = 5, @@ -70,7 +89,8 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I can help with that! What's the error message you're seeing?"), createdAt = twoDaysAgo, authorName = "Support Agent", - authorIsUser = false + authorIsUser = false, + attachments = emptyList() ) ) ), @@ -88,7 +108,21 @@ fun generateSampleHESupportConversations(): List { formattedText = AnnotatedString("I need help setting up my custom domain."), createdAt = oneWeekAgo, authorName = "You", - authorIsUser = true + authorIsUser = true, + attachments = listOf( + SupportAttachment( + id = 3, + filename = "domain-settings.pdf", + url = "https://example.com/attachments/domain-settings.pdf", + type = AttachmentType.Other + ), + SupportAttachment( + id = 4, + filename = "setup-tutorial.mp4", + url = "https://example.com/attachments/setup-tutorial.mp4", + type = AttachmentType.Video + ) + ) ) ) ) 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 ffafaf9cbbdc..3b99568e1d1a 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 @@ -372,6 +372,7 @@ class HESupportRepositoryTest : BaseUnitTest() { 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.author is SupportMessageAuthor.User, + attachments = emptyList() ) } 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 f369e47ebf5c..01575e0371a0 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 @@ -694,7 +694,8 @@ class HESupportViewModelTest : BaseUnitTest() { formattedText = AnnotatedString(text), createdAt = Date(), authorName = if (authorIsUser) "User" else "Support", - authorIsUser = authorIsUser + authorIsUser = authorIsUser, + attachments = emptyList() ) } } From bbe0ae734ef734a09c086d6cdf2bf7e207c9665a Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 12:00:00 +0100 Subject: [PATCH 142/153] Typo --- .../wordpress/android/support/he/ui/HESupportActivity.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 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 f8da1ca00bad..3ae1349a9553 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,6 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel -import org.wordpress.android.support.common.ui.ConversationsSupportViewModel.ErrorType import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.ui.photopicker.MediaPickerConstants import org.wordpress.android.ui.reader.ReaderFileDownloadManager @@ -188,7 +187,7 @@ class HESupportActivity : AppCompatActivity() { }, onClearMessageSendResult = { viewModel.clearMessageSendResult() }, attachments = attachments, - attachmentActionsListener = AttachmentActionsListener(), + attachmentActionsListener = createAttachmentActionListener(), onDownloadAttachment = { attachment -> // Show loading snackbar scope.launch { @@ -232,14 +231,14 @@ class HESupportActivity : AppCompatActivity() { userInfo = userInfo, isSendingNewConversation = isSendingNewConversation, attachments = attachments, - attachmentActionsListener = AttachmentActionsListener() + attachmentActionsListener = createAttachmentActionListener() ) } } } } - private fun AttachmentActionsListener(): AttachmentActionsListener { + private fun createAttachmentActionListener(): AttachmentActionsListener { return object : AttachmentActionsListener { override fun onAddImageClick() { val mediaPickerSetup = MediaPickerSetup( From e65b8cccf3165dbcf696c17110cc054e594df302 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 12:03:11 +0100 Subject: [PATCH 143/153] String fix --- .../org/wordpress/android/support/he/ui/HESupportActivity.kt | 2 +- WordPress/src/main/res/values/strings.xml | 3 ++- 2 files changed, 3 insertions(+), 2 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 15145bdb03c3..548224930228 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 @@ -237,7 +237,7 @@ class HESupportActivity : AppCompatActivity() { editingEnabled = true, queueResults = false, defaultSearchView = false, - title = R.string.photo_picker_title + title = R.string.he_support_select_attachments ) val intent = org.wordpress.android.ui.mediapicker.MediaPickerActivity.buildIntent( this@HESupportActivity, diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 115239420418..957a50397d79 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5178,7 +5178,8 @@ 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. - Download image + Download attachment + Select attachments Contact Support From eb02a333fa63ff791e4297eb19285bf0b60acf71 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 12:53:04 +0100 Subject: [PATCH 144/153] Fixing pan issue --- .../he/ui/AttachmentFullscreenImagePreview.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt index 5c98026e1cf4..41d616ad486f 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt @@ -102,11 +102,18 @@ fun AttachmentFullscreenImagePreview( ) .pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> + val previousScale = scale scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { - offsetX += pan.x - offsetY += pan.y - } else { + // Calculate max pan bounds to prevent image from going off-screen + val maxOffsetX = (size.width * (scale - 1f)) / 2f + val maxOffsetY = (size.height * (scale - 1f)) / 2f + + offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX) + offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY) + } else if (previousScale > 1f && scale == 1f) { + // Only reset when transitioning from zoomed to unzoomed offsetX = 0f offsetY = 0f } From 900cdaff4e8e9ecee0720b137f6178f57b02241d Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 13:00:56 +0100 Subject: [PATCH 145/153] Passing attachments directly instead of searching for then when tapped for full screen --- .../support/he/ui/HEConversationDetailScreen.kt | 15 ++++++++------- 1 file changed, 8 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 b27be0296be8..57c241f8c6e2 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 @@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp import coil.request.ImageRequest import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime +import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage import org.wordpress.android.support.he.util.AttachmentActionsListener @@ -149,7 +150,7 @@ fun HEConversationDetailScreen( MessageItem( message = message, timestamp = formatRelativeTime(message.createdAt, resources), - onPreviewImage = { imageUrl -> previewImageUrl = imageUrl }, + onPreviewImage = { attachment -> previewImageUrl = attachment.url }, onDownloadAttachment = onDownloadAttachment ) } @@ -288,8 +289,8 @@ private fun ConversationTitleCard(title: String) { private fun MessageItem( message: SupportMessage, timestamp: String, - onPreviewImage: (String) -> Unit, - onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit + onPreviewImage: (SupportAttachment) -> Unit, + onDownloadAttachment: (SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -359,9 +360,9 @@ private fun MessageItem( @Composable private fun AttachmentsList( - attachments: List, - onPreviewImage: (String) -> Unit, - onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit + attachments: List, + onPreviewImage: (SupportAttachment) -> Unit, + onDownloadAttachment: (SupportAttachment) -> Unit ) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -372,7 +373,7 @@ private fun AttachmentsList( attachment = attachment, onClick = { if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { - onPreviewImage(attachment.url) + onPreviewImage(attachment) } else { onDownloadAttachment(attachment) } From 315083af76f54d6184da08cef357216f72dedbbf Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 13:23:09 +0100 Subject: [PATCH 146/153] Compile fix --- .../android/support/he/ui/AttachmentFullscreenImagePreview.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt index 41d616ad486f..66aa7f362d8a 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt @@ -152,7 +152,7 @@ fun AttachmentFullscreenImagePreview( ) { Icon( painter = painterResource(R.drawable.ic_get_app_white_24dp), - contentDescription = stringResource(R.string.he_support_download_image), + contentDescription = stringResource(R.string.he_support_download_attachment), tint = Color.White, modifier = Modifier.size(24.dp) ) From 031fb069ac99f11882da6dcf63ff64efb3a3c635 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 15:53:55 +0100 Subject: [PATCH 147/153] Fixing the send state message --- .../he/ui/HEConversationDetailScreen.kt | 36 +++++++++++++++---- .../he/ui/HEConversationReplyBottomSheet.kt | 21 ----------- 2 files changed, 29 insertions(+), 28 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 57c241f8c6e2..c8a7a8c2e4ff 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 @@ -169,10 +169,38 @@ fun HEConversationDetailScreen( } if (showBottomSheet) { + // Close the sheet when sending completes successfully + LaunchedEffect(messageSendResult) { + @OptIn(ExperimentalMaterial3Api::class) + fun dismissSheet() { + onClearMessageSendResult() + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + showBottomSheet = false + } + } + + when (messageSendResult) { + is HESupportViewModel.MessageSendResult.Success -> { + // Clear draft after successful send and dismiss the button sheet + draftMessageText = "" + draftIncludeAppLogs = false + dismissSheet() + } + is HESupportViewModel.MessageSendResult.Failure -> { + // Message failed to send, draft is saved + dismissSheet() + } + null -> { + // No result yet, do nothing + } + } + } + HEConversationReplyBottomSheet( sheetState = sheetState, isSending = isSendingMessage, - messageSendResult = messageSendResult, initialMessageText = draftMessageText, initialIncludeAppLogs = draftIncludeAppLogs, onDismiss = { currentMessage, currentIncludeAppLogs -> @@ -188,12 +216,6 @@ fun HEConversationDetailScreen( onSend = { message, includeAppLogs -> onSendMessage(message, includeAppLogs) }, - onMessageSentSuccessfully = { - // Clear draft after successful send - draftMessageText = "" - draftIncludeAppLogs = false - onClearMessageSendResult() - }, attachments = attachments, attachmentActionsListener = attachmentActionsListener ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index 15e0c1cfb6ea..8cfd2f017d62 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -37,12 +37,10 @@ import org.wordpress.android.support.he.util.AttachmentActionsListener fun HEConversationReplyBottomSheet( 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, - onMessageSentSuccessfully: () -> Unit, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener ) { @@ -50,25 +48,6 @@ fun HEConversationReplyBottomSheet( var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } val scrollState = rememberScrollState() - // Close the sheet when sending completes successfully - 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 - } - } - } - ModalBottomSheet( onDismissRequest = { onDismiss(messageText, includeAppLogs) }, sheetState = sheetState From 157352034c721e8d3cabadea7cb84d3e98ae1647 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 16:04:56 +0100 Subject: [PATCH 148/153] Checking network availability --- .../android/support/aibot/ui/AIBotSupportActivity.kt | 1 + .../common/ui/ConversationsSupportViewModel.kt | 12 +++++++++++- .../android/support/he/ui/HESupportActivity.kt | 1 + .../android/support/he/ui/HESupportViewModel.kt | 11 +++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) 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 10adaf9963fb..843180b87b63 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 @@ -93,6 +93,7 @@ class AIBotSupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } scope.launch { snackbarHostState.showSnackbar( 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 1b3f613e2061..96e3dda5f677 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 @@ -19,7 +19,7 @@ import org.wordpress.android.util.NetworkUtilsWrapper abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, - private val networkUtilsWrapper: NetworkUtilsWrapper, + protected val networkUtilsWrapper: NetworkUtilsWrapper, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToConversationDetail : NavigationEvent() @@ -140,6 +140,11 @@ abstract class ConversationsSupportViewModel( fun onConversationClick(conversation: ConversationType) { viewModelScope.launch { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + _isLoadingConversation.value = true _selectedConversation.value = conversation _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) @@ -173,6 +178,10 @@ abstract class ConversationsSupportViewModel( fun onCreateNewConversationClick() { viewModelScope.launch { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } _navigationEvents.emit(NavigationEvent.NavigateToNewConversation) } } @@ -182,5 +191,6 @@ abstract class ConversationsSupportViewModel( enum class ErrorType { GENERAL, FORBIDDEN, + OFFLINE, } } 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 e6c2f9861f68..bdaa22bd6160 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 @@ -123,6 +123,7 @@ class HESupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } 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 c7a906fc1c72..39612f657acb 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 @@ -59,6 +59,11 @@ class HESupportViewModel @Inject constructor( ) { viewModelScope.launch(ioDispatcher) { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + _isSendingMessage.value = true val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) @@ -108,6 +113,12 @@ class HESupportViewModel @Inject constructor( fun onAddMessageToConversation(message: String) { viewModelScope.launch(ioDispatcher) { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _messageSendResult.value = MessageSendResult.Failure + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + val selectedConversation = _selectedConversation.value if (selectedConversation == null) { appLogWrapper.e(AppLog.T.SUPPORT, "Error answering a conversation: no conversation selected") From 2006d6351433c8fa0939baadbe8426584a348d2a Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 16:15:36 +0100 Subject: [PATCH 149/153] Saving message state when error --- .../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 c8a7a8c2e4ff..366f39b62d79 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 @@ -204,7 +204,6 @@ fun HEConversationDetailScreen( initialMessageText = draftMessageText, initialIncludeAppLogs = draftIncludeAppLogs, onDismiss = { currentMessage, currentIncludeAppLogs -> - // Save draft message when closing without sending draftMessageText = currentMessage draftIncludeAppLogs = currentIncludeAppLogs scope.launch { @@ -214,6 +213,7 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> + draftMessageText = message onSendMessage(message, includeAppLogs) }, attachments = attachments, From 80bedeb8584856a2181123bc9cece10f8392f9d5 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 16:23:41 +0100 Subject: [PATCH 150/153] Tests --- .../he/ui/HEConversationReplyBottomSheet.kt | 1 - .../ui/ConversationsSupportViewModelTest.kt | 86 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index 8cfd2f017d62..a57b11e0ea8a 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 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 03f47f2791e9..209c02cc2a8d 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 @@ -148,6 +148,17 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } + @Test + fun `init sets NoNetwork state when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) + assertThat(viewModel.conversations.value).isEmpty() + } + // Refresh Conversations Tests @Test @@ -180,6 +191,22 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) } + @Test + fun `refreshConversations sets NoNetwork state when network is not available`() = test { + val initialConversations = createTestConversations(count = 2) + viewModel.setConversationsToReturn(initialConversations) + viewModel.init() + advanceUntilIdle() + + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + viewModel.refreshConversations() + advanceUntilIdle() + + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) + // Conversations should remain unchanged from previous load + assertThat(viewModel.conversations.value).isEqualTo(initialConversations) + } + // Clear Error Tests @Test @@ -275,6 +302,37 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } + @Test + fun `onConversationClick sets OFFLINE error when network is not available`() = test { + val conversation = createTestConversation(1) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + assertThat(viewModel.isLoadingConversation.value).isFalse + } + + @Test + fun `onConversationClick does not navigate when network is not available`() = test { + val conversation = createTestConversation(1) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(emittedEvent).isNull() + job.cancel() + } + @Test fun `onBackFromDetailClick clears selected conversation`() = test { val conversation = createTestConversation(1) @@ -322,6 +380,34 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { job.cancel() } + @Test + fun `onCreateNewConversationClick sets OFFLINE error when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onCreateNewConversationClick() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + } + + @Test + fun `onCreateNewConversationClick does not navigate when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onCreateNewConversationClick() + advanceUntilIdle() + + assertThat(emittedEvent).isNull() + job.cancel() + } + @Test fun `setNewConversation sets selected conversation and emits navigation event`() = test { val conversation = createTestConversation(1) From 0e058dc7717eaedad56f3e13697fd6b59c4c306c Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 16:30:11 +0100 Subject: [PATCH 151/153] Reverting non-related commits done by mistake --- .../support/aibot/ui/AIBotSupportActivity.kt | 1 - .../ui/ConversationsSupportViewModel.kt | 12 +-- .../he/ui/HEConversationDetailScreen.kt | 38 ++------ .../he/ui/HEConversationReplyBottomSheet.kt | 22 +++++ .../support/he/ui/HESupportActivity.kt | 1 - .../support/he/ui/HESupportViewModel.kt | 11 --- .../ui/ConversationsSupportViewModelTest.kt | 86 ------------------- 7 files changed, 31 insertions(+), 140 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 843180b87b63..10adaf9963fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt @@ -93,7 +93,6 @@ class AIBotSupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) - ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } scope.launch { snackbarHostState.showSnackbar( 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 96e3dda5f677..1b3f613e2061 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 @@ -19,7 +19,7 @@ import org.wordpress.android.util.NetworkUtilsWrapper abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, - protected val networkUtilsWrapper: NetworkUtilsWrapper, + private val networkUtilsWrapper: NetworkUtilsWrapper, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToConversationDetail : NavigationEvent() @@ -140,11 +140,6 @@ abstract class ConversationsSupportViewModel( fun onConversationClick(conversation: ConversationType) { viewModelScope.launch { try { - if (!networkUtilsWrapper.isNetworkAvailable()) { - _errorMessage.value = ErrorType.OFFLINE - return@launch - } - _isLoadingConversation.value = true _selectedConversation.value = conversation _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) @@ -178,10 +173,6 @@ abstract class ConversationsSupportViewModel( fun onCreateNewConversationClick() { viewModelScope.launch { - if (!networkUtilsWrapper.isNetworkAvailable()) { - _errorMessage.value = ErrorType.OFFLINE - return@launch - } _navigationEvents.emit(NavigationEvent.NavigateToNewConversation) } } @@ -191,6 +182,5 @@ abstract class ConversationsSupportViewModel( enum class ErrorType { GENERAL, FORBIDDEN, - OFFLINE, } } 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 366f39b62d79..57c241f8c6e2 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 @@ -169,41 +169,14 @@ fun HEConversationDetailScreen( } if (showBottomSheet) { - // Close the sheet when sending completes successfully - LaunchedEffect(messageSendResult) { - @OptIn(ExperimentalMaterial3Api::class) - fun dismissSheet() { - onClearMessageSendResult() - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - showBottomSheet = false - } - } - - when (messageSendResult) { - is HESupportViewModel.MessageSendResult.Success -> { - // Clear draft after successful send and dismiss the button sheet - draftMessageText = "" - draftIncludeAppLogs = false - dismissSheet() - } - is HESupportViewModel.MessageSendResult.Failure -> { - // Message failed to send, draft is saved - dismissSheet() - } - null -> { - // No result yet, do nothing - } - } - } - HEConversationReplyBottomSheet( sheetState = sheetState, isSending = isSendingMessage, + messageSendResult = messageSendResult, initialMessageText = draftMessageText, initialIncludeAppLogs = draftIncludeAppLogs, onDismiss = { currentMessage, currentIncludeAppLogs -> + // Save draft message when closing without sending draftMessageText = currentMessage draftIncludeAppLogs = currentIncludeAppLogs scope.launch { @@ -213,9 +186,14 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> - draftMessageText = message onSendMessage(message, includeAppLogs) }, + onMessageSentSuccessfully = { + // Clear draft after successful send + draftMessageText = "" + draftIncludeAppLogs = false + onClearMessageSendResult() + }, attachments = attachments, attachmentActionsListener = attachmentActionsListener ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index a57b11e0ea8a..15e0c1cfb6ea 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 @@ -36,10 +37,12 @@ import org.wordpress.android.support.he.util.AttachmentActionsListener fun HEConversationReplyBottomSheet( 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, + onMessageSentSuccessfully: () -> Unit, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener ) { @@ -47,6 +50,25 @@ fun HEConversationReplyBottomSheet( var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } val scrollState = rememberScrollState() + // Close the sheet when sending completes successfully + 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 + } + } + } + ModalBottomSheet( onDismissRequest = { onDismiss(messageText, includeAppLogs) }, sheetState = sheetState 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 bdaa22bd6160..e6c2f9861f68 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 @@ -123,7 +123,6 @@ class HESupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) - ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } 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 39612f657acb..c7a906fc1c72 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 @@ -59,11 +59,6 @@ class HESupportViewModel @Inject constructor( ) { viewModelScope.launch(ioDispatcher) { try { - if (!networkUtilsWrapper.isNetworkAvailable()) { - _errorMessage.value = ErrorType.OFFLINE - return@launch - } - _isSendingMessage.value = true val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) @@ -113,12 +108,6 @@ class HESupportViewModel @Inject constructor( fun onAddMessageToConversation(message: String) { viewModelScope.launch(ioDispatcher) { try { - if (!networkUtilsWrapper.isNetworkAvailable()) { - _messageSendResult.value = MessageSendResult.Failure - _errorMessage.value = ErrorType.OFFLINE - return@launch - } - val selectedConversation = _selectedConversation.value if (selectedConversation == null) { appLogWrapper.e(AppLog.T.SUPPORT, "Error answering a conversation: no conversation selected") 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 209c02cc2a8d..03f47f2791e9 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModelTest.kt @@ -148,17 +148,6 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } - @Test - fun `init sets NoNetwork state when network is not available`() = test { - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - - viewModel.init() - advanceUntilIdle() - - assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) - assertThat(viewModel.conversations.value).isEmpty() - } - // Refresh Conversations Tests @Test @@ -191,22 +180,6 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) } - @Test - fun `refreshConversations sets NoNetwork state when network is not available`() = test { - val initialConversations = createTestConversations(count = 2) - viewModel.setConversationsToReturn(initialConversations) - viewModel.init() - advanceUntilIdle() - - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - viewModel.refreshConversations() - advanceUntilIdle() - - assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) - // Conversations should remain unchanged from previous load - assertThat(viewModel.conversations.value).isEqualTo(initialConversations) - } - // Clear Error Tests @Test @@ -302,37 +275,6 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } - @Test - fun `onConversationClick sets OFFLINE error when network is not available`() = test { - val conversation = createTestConversation(1) - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - - viewModel.onConversationClick(conversation) - advanceUntilIdle() - - assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) - assertThat(viewModel.isLoadingConversation.value).isFalse - } - - @Test - fun `onConversationClick does not navigate when network is not available`() = test { - val conversation = createTestConversation(1) - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - - var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null - val job = launch { - viewModel.navigationEvents.collect { event -> - emittedEvent = event - } - } - - viewModel.onConversationClick(conversation) - advanceUntilIdle() - - assertThat(emittedEvent).isNull() - job.cancel() - } - @Test fun `onBackFromDetailClick clears selected conversation`() = test { val conversation = createTestConversation(1) @@ -380,34 +322,6 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { job.cancel() } - @Test - fun `onCreateNewConversationClick sets OFFLINE error when network is not available`() = test { - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - - viewModel.onCreateNewConversationClick() - advanceUntilIdle() - - assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) - } - - @Test - fun `onCreateNewConversationClick does not navigate when network is not available`() = test { - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - - var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null - val job = launch { - viewModel.navigationEvents.collect { event -> - emittedEvent = event - } - } - - viewModel.onCreateNewConversationClick() - advanceUntilIdle() - - assertThat(emittedEvent).isNull() - job.cancel() - } - @Test fun `setNewConversation sets selected conversation and emits navigation event`() = test { val conversation = createTestConversation(1) From f0c26182dc7432e95edd32cd9fc5671b474039a4 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 16:39:14 +0100 Subject: [PATCH 152/153] Reset files due to a wrong commit --- .../support/aibot/ui/AIBotSupportActivity.kt | 1 + .../ui/ConversationsSupportViewModel.kt | 12 ++- .../he/ui/HEConversationDetailScreen.kt | 38 ++++++-- .../he/ui/HEConversationReplyBottomSheet.kt | 22 ----- .../support/he/ui/HESupportActivity.kt | 1 + .../support/he/ui/HESupportViewModel.kt | 11 +++ .../ui/ConversationsSupportViewModelTest.kt | 86 +++++++++++++++++++ 7 files changed, 140 insertions(+), 31 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 10adaf9963fb..843180b87b63 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 @@ -93,6 +93,7 @@ class AIBotSupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } scope.launch { snackbarHostState.showSnackbar( 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 1b3f613e2061..96e3dda5f677 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 @@ -19,7 +19,7 @@ import org.wordpress.android.util.NetworkUtilsWrapper abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, - private val networkUtilsWrapper: NetworkUtilsWrapper, + protected val networkUtilsWrapper: NetworkUtilsWrapper, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToConversationDetail : NavigationEvent() @@ -140,6 +140,11 @@ abstract class ConversationsSupportViewModel( fun onConversationClick(conversation: ConversationType) { viewModelScope.launch { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + _isLoadingConversation.value = true _selectedConversation.value = conversation _navigationEvents.emit(NavigationEvent.NavigateToConversationDetail) @@ -173,6 +178,10 @@ abstract class ConversationsSupportViewModel( fun onCreateNewConversationClick() { viewModelScope.launch { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } _navigationEvents.emit(NavigationEvent.NavigateToNewConversation) } } @@ -182,5 +191,6 @@ abstract class ConversationsSupportViewModel( enum class ErrorType { GENERAL, FORBIDDEN, + OFFLINE, } } 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 57c241f8c6e2..366f39b62d79 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 @@ -169,14 +169,41 @@ fun HEConversationDetailScreen( } if (showBottomSheet) { + // Close the sheet when sending completes successfully + LaunchedEffect(messageSendResult) { + @OptIn(ExperimentalMaterial3Api::class) + fun dismissSheet() { + onClearMessageSendResult() + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + showBottomSheet = false + } + } + + when (messageSendResult) { + is HESupportViewModel.MessageSendResult.Success -> { + // Clear draft after successful send and dismiss the button sheet + draftMessageText = "" + draftIncludeAppLogs = false + dismissSheet() + } + is HESupportViewModel.MessageSendResult.Failure -> { + // Message failed to send, draft is saved + dismissSheet() + } + null -> { + // No result yet, do nothing + } + } + } + HEConversationReplyBottomSheet( sheetState = sheetState, isSending = isSendingMessage, - messageSendResult = messageSendResult, initialMessageText = draftMessageText, initialIncludeAppLogs = draftIncludeAppLogs, onDismiss = { currentMessage, currentIncludeAppLogs -> - // Save draft message when closing without sending draftMessageText = currentMessage draftIncludeAppLogs = currentIncludeAppLogs scope.launch { @@ -186,14 +213,9 @@ fun HEConversationDetailScreen( } }, onSend = { message, includeAppLogs -> + draftMessageText = message onSendMessage(message, includeAppLogs) }, - onMessageSentSuccessfully = { - // Clear draft after successful send - draftMessageText = "" - draftIncludeAppLogs = false - onClearMessageSendResult() - }, attachments = attachments, attachmentActionsListener = attachmentActionsListener ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt index 15e0c1cfb6ea..a57b11e0ea8a 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationReplyBottomSheet.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 @@ -37,12 +36,10 @@ import org.wordpress.android.support.he.util.AttachmentActionsListener fun HEConversationReplyBottomSheet( 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, - onMessageSentSuccessfully: () -> Unit, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener ) { @@ -50,25 +47,6 @@ fun HEConversationReplyBottomSheet( var includeAppLogs by remember { mutableStateOf(initialIncludeAppLogs) } val scrollState = rememberScrollState() - // Close the sheet when sending completes successfully - 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 - } - } - } - ModalBottomSheet( onDismissRequest = { onDismiss(messageText, includeAppLogs) }, sheetState = sheetState 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 e6c2f9861f68..bdaa22bd6160 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 @@ -123,6 +123,7 @@ class HESupportActivity : AppCompatActivity() { val message = when (errorType) { ConversationsSupportViewModel.ErrorType.GENERAL -> getString(R.string.he_support_generic_error) ConversationsSupportViewModel.ErrorType.FORBIDDEN -> getString(R.string.he_support_forbidden_error) + ConversationsSupportViewModel.ErrorType.OFFLINE -> getString(R.string.no_network_title) } 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 c7a906fc1c72..39612f657acb 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 @@ -59,6 +59,11 @@ class HESupportViewModel @Inject constructor( ) { viewModelScope.launch(ioDispatcher) { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + _isSendingMessage.value = true val files = tempAttachmentsUtil.createTempFilesFrom(_attachments.value) @@ -108,6 +113,12 @@ class HESupportViewModel @Inject constructor( fun onAddMessageToConversation(message: String) { viewModelScope.launch(ioDispatcher) { try { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _messageSendResult.value = MessageSendResult.Failure + _errorMessage.value = ErrorType.OFFLINE + return@launch + } + val selectedConversation = _selectedConversation.value if (selectedConversation == null) { appLogWrapper.e(AppLog.T.SUPPORT, "Error answering a conversation: no conversation selected") 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 03f47f2791e9..209c02cc2a8d 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 @@ -148,6 +148,17 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } + @Test + fun `init sets NoNetwork state when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.init() + advanceUntilIdle() + + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) + assertThat(viewModel.conversations.value).isEmpty() + } + // Refresh Conversations Tests @Test @@ -180,6 +191,22 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.Error.javaClass) } + @Test + fun `refreshConversations sets NoNetwork state when network is not available`() = test { + val initialConversations = createTestConversations(count = 2) + viewModel.setConversationsToReturn(initialConversations) + viewModel.init() + advanceUntilIdle() + + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + viewModel.refreshConversations() + advanceUntilIdle() + + assertThat(viewModel.conversationsState.value).isInstanceOf(ConversationsState.NoNetwork.javaClass) + // Conversations should remain unchanged from previous load + assertThat(viewModel.conversations.value).isEqualTo(initialConversations) + } + // Clear Error Tests @Test @@ -275,6 +302,37 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { verify(appLogWrapper).e(any(), any()) } + @Test + fun `onConversationClick sets OFFLINE error when network is not available`() = test { + val conversation = createTestConversation(1) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + assertThat(viewModel.isLoadingConversation.value).isFalse + } + + @Test + fun `onConversationClick does not navigate when network is not available`() = test { + val conversation = createTestConversation(1) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onConversationClick(conversation) + advanceUntilIdle() + + assertThat(emittedEvent).isNull() + job.cancel() + } + @Test fun `onBackFromDetailClick clears selected conversation`() = test { val conversation = createTestConversation(1) @@ -322,6 +380,34 @@ class ConversationsSupportViewModelTest : BaseUnitTest() { job.cancel() } + @Test + fun `onCreateNewConversationClick sets OFFLINE error when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onCreateNewConversationClick() + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + } + + @Test + fun `onCreateNewConversationClick does not navigate when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + var emittedEvent: ConversationsSupportViewModel.NavigationEvent? = null + val job = launch { + viewModel.navigationEvents.collect { event -> + emittedEvent = event + } + } + + viewModel.onCreateNewConversationClick() + advanceUntilIdle() + + assertThat(emittedEvent).isNull() + job.cancel() + } + @Test fun `setNewConversation sets selected conversation and emits navigation event`() = test { val conversation = createTestConversation(1) From 0a3c5773ee372b99d6213125360eb9531c1ab21e Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 3 Nov 2025 16:57:19 +0100 Subject: [PATCH 153/153] Duplicated code and tests fix --- .../he/ui/HEConversationDetailScreen.kt | 28 ++---- .../support/he/ui/HESupportViewModelTest.kt | 91 +++++++++++++++++++ 2 files changed, 100 insertions(+), 19 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 366f39b62d79..c0460f788c55 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 @@ -169,10 +169,16 @@ fun HEConversationDetailScreen( } if (showBottomSheet) { - // Close the sheet when sending completes successfully + // Close the sheet when sending completes LaunchedEffect(messageSendResult) { - @OptIn(ExperimentalMaterial3Api::class) - fun dismissSheet() { + if (messageSendResult != null) { + // Clear draft only on success + if (messageSendResult is HESupportViewModel.MessageSendResult.Success) { + draftMessageText = "" + draftIncludeAppLogs = false + } + + // Dismiss sheet and clear result for both success and failure onClearMessageSendResult() scope.launch { sheetState.hide() @@ -180,22 +186,6 @@ fun HEConversationDetailScreen( showBottomSheet = false } } - - when (messageSendResult) { - is HESupportViewModel.MessageSendResult.Success -> { - // Clear draft after successful send and dismiss the button sheet - draftMessageText = "" - draftIncludeAppLogs = false - dismissSheet() - } - is HESupportViewModel.MessageSendResult.Failure -> { - // Message failed to send, draft is saved - dismissSheet() - } - null -> { - // No result yet, do nothing - } - } } HEConversationReplyBottomSheet( 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 01575e0371a0..46359d635bed 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 @@ -12,6 +12,7 @@ import org.mockito.Mock import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest @@ -202,6 +203,35 @@ class HESupportViewModelTest : BaseUnitTest() { assertThat(viewModel.isSendingMessage.value).isFalse } + @Test + fun `onSendNewConversation sets OFFLINE error when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1") + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + assertThat(viewModel.isSendingMessage.value).isFalse + } + + @Test + fun `onSendNewConversation does not call repository when network is not available`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onSendNewConversation( + subject = "Test Subject", + message = "Test Message", + tags = listOf("tag1") + ) + advanceUntilIdle() + + verify(heSupportRepository, never()).createConversation(any(), any(), any(), any()) + } + // endregion // region getConversation() override tests @@ -347,6 +377,67 @@ class HESupportViewModelTest : BaseUnitTest() { assertThat(viewModel.isSendingMessage.value).isFalse } + @Test + fun `onAddMessageToConversation sets OFFLINE error when network is not available`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + + // Network available when loading conversation + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + // Network unavailable when sending message + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(ConversationsSupportViewModel.ErrorType.OFFLINE) + assertThat(viewModel.isSendingMessage.value).isFalse + } + + @Test + fun `onAddMessageToConversation sets Failure result when network is not available`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + + // Network available when loading conversation + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + // Network unavailable when sending message + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + assertThat(viewModel.messageSendResult.value).isEqualTo(HESupportViewModel.MessageSendResult.Failure) + } + + @Test + fun `onAddMessageToConversation does not call repository when network is not available`() = test { + val existingConversation = createTestConversation(1) + whenever(heSupportRepository.loadConversation(1L)).thenReturn(existingConversation) + + // Network available when loading conversation + viewModel.onConversationClick(existingConversation) + advanceUntilIdle() + + // Network unavailable when sending message + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.onAddMessageToConversation( + message = "Test message" + ) + advanceUntilIdle() + + verify(heSupportRepository, never()).addMessageToConversation(any(), any(), any()) + } + // endregion // region Attachment management tests