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/AIBotConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt index ab8386bdcf7b..e7444641d92b 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,10 @@ 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.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -195,10 +199,17 @@ fun AIBotConversationDetailScreen( @Composable private fun WelcomeHeader(userName: String) { + val greeting = stringResource(R.string.ai_bot_welcome_greeting, userName) + val message = stringResource(R.string.ai_bot_welcome_message) + val welcomeDescription = "$greeting. $message" + Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .clearAndSetSemantics { + contentDescription = welcomeDescription + }, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), @@ -220,7 +231,8 @@ private fun WelcomeHeader(userName: String) { text = stringResource(R.string.ai_bot_welcome_greeting, userName), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() } ) Text( @@ -241,6 +253,7 @@ private fun ChatInputBar( onSendClick: () -> Unit ) { val canSend = messageText.isNotBlank() && canSendMessage + val messageInputLabel = stringResource(R.string.ai_bot_message_input_placeholder) Row( modifier = Modifier @@ -253,8 +266,10 @@ private fun ChatInputBar( OutlinedTextField( value = messageText, onValueChange = onMessageTextChange, - modifier = Modifier.weight(1f), - placeholder = { Text(stringResource(R.string.ai_bot_message_input_placeholder)) }, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = messageInputLabel }, + placeholder = { Text(messageInputLabel) }, maxLines = 4, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) @@ -278,6 +293,10 @@ private fun ChatInputBar( @Composable private fun MessageBubble(message: BotMessage, resources: android.content.res.Resources) { + val timestamp = formatRelativeTime(message.date, resources) + val author = stringResource(if (message.isWrittenByUser) R.string.ai_bot_you else R.string.ai_bot_support_bot) + val messageDescription = "$author, $timestamp. ${message.formattedText}" + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (message.isWrittenByUser) { @@ -303,6 +322,9 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re ) ) .padding(12.dp) + .clearAndSetSemantics { + contentDescription = messageDescription + } ) { Column { Text( @@ -318,7 +340,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 +371,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/aibot/ui/AIBotConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationsListScreen.kt index 57752a0fb9ee..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,28 +5,14 @@ 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.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,109 +23,47 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import org.wordpress.android.R import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.aibot.util.generateSampleBotConversations -import org.wordpress.android.support.common.ui.EmptyConversationsView +import org.wordpress.android.support.common.ui.ConversationsListScreen +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun AIBotConversationsListScreen( snackbarHostState: SnackbarHostState, - conversations: StateFlow>, - isLoading: Boolean, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, onConversationClick: (BotConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, onRefresh: () -> Unit, ) { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.ai_bot_conversations_title)) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(R.string.ai_bot_back_button_content_description) - ) - } - }, - actions = { - IconButton(onClick = { onCreateNewConversationClick() }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.ai_bot_new_conversation_content_description) - ) - } - } - ) - }, - ) { contentPadding -> - val conversationsList by conversations.collectAsState() - - PullToRefreshBox( - isRefreshing = isLoading, - onRefresh = onRefresh, - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - ) { - when { - conversationsList.isEmpty() && !isLoading -> { - EmptyConversationsView( - modifier = Modifier.fillMaxSize(), - onCreateNewConversationClick = onCreateNewConversationClick - ) - } - else -> { - ShowConversationsList( - modifier = Modifier.fillMaxSize(), - conversations = conversations, - onConversationClick = onConversationClick - ) - } - } - } - } -} - -@Composable -private fun ShowConversationsList( - modifier: Modifier, - conversations: StateFlow>, - onConversationClick: (BotConversation) -> Unit -) { - val conversations by conversations.collectAsState() val resources = LocalResources.current - - LazyColumn( - modifier = modifier.fillMaxSize() - ) { - 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, + conversationsState = conversationsState, + 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,14 +107,13 @@ 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(), - isLoading = false, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -202,14 +125,13 @@ private fun ConversationsScreenPreview() { @Preview(showBackground = true, name = "Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewDark() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoading = false, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -221,14 +143,13 @@ private fun ConversationsScreenPreviewDark() { @Preview(showBackground = true, name = "Conversations List") @Composable private fun ConversationsScreenWordPressPreview() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoading = true, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -240,14 +161,13 @@ private fun ConversationsScreenWordPressPreview() { @Preview(showBackground = true, name = "Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewWordPressDark() { - val sampleConversations = MutableStateFlow(generateSampleBotConversations()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoading = true, + conversations = generateSampleBotConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -259,14 +179,13 @@ private fun ConversationsScreenPreviewWordPressDark() { @Preview(showBackground = true, name = "Empty Conversations List") @Composable private fun EmptyConversationsScreenPreview() { - val emptyConversations = MutableStateFlow(emptyList()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = emptyConversations.asStateFlow(), - isLoading = false, + conversations = emptyList(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -278,14 +197,13 @@ private fun EmptyConversationsScreenPreview() { @Preview(showBackground = true, name = "Empty Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun EmptyConversationsScreenPreviewDark() { - val emptyConversations = MutableStateFlow(emptyList()) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = emptyConversations.asStateFlow(), - isLoading = false, + conversations = emptyList(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt index 6d6123dc53ba..10adaf9963fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt @@ -109,11 +109,12 @@ class AIBotSupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name, ) { composable(route = ConversationScreen.List.name) { - val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() + val conversationsState by viewModel.conversationsState.collectAsState() + val conversations by viewModel.conversations.collectAsState() AIBotConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = viewModel.conversations, - isLoading = isLoadingConversations, + conversations = conversations, + conversationsState = conversationsState, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, 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 new file mode 100644 index 000000000000..801221632345 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsListScreen.kt @@ -0,0 +1,105 @@ +package org.wordpress.android.support.common.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.wordpress.android.support.common.model.Conversation +import org.wordpress.android.ui.compose.components.MainTopAppBar +import org.wordpress.android.ui.compose.components.NavigationIcons + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationsListScreen( + modifier: Modifier = Modifier, + title: String, + addConversationContentDescription: String, + snackbarHostState: SnackbarHostState, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, + onBackClick: () -> Unit, + onCreateNewConversationClick: () -> Unit, + onRefresh: () -> Unit, + conversationListItem: @Composable (T) -> Unit +) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + MainTopAppBar( + title = title, + navigationIcon = NavigationIcons.BackIcon, + onNavigationIconClick = onBackClick, + actions = { + IconButton(onClick = { onCreateNewConversationClick() }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = addConversationContentDescription + ) + } + } + ) + } + ) { contentPadding -> + PullToRefreshBox( + isRefreshing = conversationsState is ConversationsSupportViewModel.ConversationsState.Loading, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize() + ) { + ConversationsList( + modifier = Modifier.padding(contentPadding), + conversations = conversations, + conversationsState = conversationsState, + onCreateNewConversationClick = onCreateNewConversationClick, + conversationListItem = conversationListItem, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationsList( + modifier: Modifier, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, + onCreateNewConversationClick: () -> Unit, + conversationListItem: @Composable (T) -> Unit +) { + if (conversations.isEmpty() && conversationsState is ConversationsSupportViewModel.ConversationsState.Loaded) { + EmptyConversationsView( + modifier = modifier, + onCreateNewConversationClick = onCreateNewConversationClick + ) + } else if (conversationsState is ConversationsSupportViewModel.ConversationsState.NoNetwork) { + OfflineConversationsView() + } else if (conversationsState is ConversationsSupportViewModel.ConversationsState.Error) { + ErrorConversationsView() + } else { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { + items( + items = conversations, + key = { it.getConversationId() } + ) { conversation -> + conversationListItem(conversation) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt index b9bf9ef6d692..7b99c87a740f 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt @@ -14,10 +14,12 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.model.Conversation import org.wordpress.android.support.common.model.UserInfo import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper abstract class ConversationsSupportViewModel( protected val accountStore: AccountStore, protected val appLogWrapper: AppLogWrapper, + private val networkUtilsWrapper: NetworkUtilsWrapper, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToConversationDetail : NavigationEvent() @@ -25,6 +27,13 @@ abstract class ConversationsSupportViewModel( data object NavigateBack : NavigationEvent() } + sealed class ConversationsState { + data object Loading : ConversationsState() + data object Loaded : ConversationsState() + data object NoNetwork : ConversationsState() + data object Error : ConversationsState() + } + private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() @@ -44,8 +53,8 @@ abstract class ConversationsSupportViewModel( val userInfo: StateFlow = _userInfo.asStateFlow() @Suppress("VariableNaming") - protected val _isLoadingConversations = MutableStateFlow(false) - val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() + protected val _conversationsState = MutableStateFlow(ConversationsState.Loading) + val conversationsState: StateFlow = _conversationsState.asStateFlow() @Suppress("VariableNaming") protected val _errorMessage = MutableStateFlow(null) @@ -88,17 +97,23 @@ abstract class ConversationsSupportViewModel( @Suppress("TooGenericExceptionCaught") private suspend fun loadConversations() { try { - _isLoadingConversations.value = true + if (!networkUtilsWrapper.isNetworkAvailable()) { + _conversationsState.value = ConversationsState.NoNetwork + return + } + + _conversationsState.value = ConversationsState.Loading val conversations = getConversations() _conversations.value = conversations + _conversationsState.value = ConversationsState.Loaded } catch (throwable: Throwable) { _errorMessage.value = ErrorType.GENERAL + _conversationsState.value = ConversationsState.Error appLogWrapper.e( AppLog.T.SUPPORT, "Error loading support conversations: " + "${throwable.message} - ${throwable.stackTraceToString()}" ) } - _isLoadingConversations.value = false } protected abstract suspend fun getConversations(): List diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/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/HEConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt index 28f4b3a42dbb..f355d85a350b 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,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -188,10 +192,20 @@ private fun ConversationHeader( lastUpdated: String, isLoading: Boolean = false ) { + val headerDescription = if (!isLoading) { + "${stringResource(R.string.he_support_message_count, messageCount)}. " + + stringResource(R.string.he_support_last_updated, lastUpdated) + } else { + stringResource(R.string.he_support_last_updated, lastUpdated) + } + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + .clearAndSetSemantics { + contentDescription = headerDescription + }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -235,7 +249,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 +260,8 @@ private fun MessageItem( message: SupportMessage, timestamp: String ) { + val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" + Box( modifier = Modifier .fillMaxWidth() @@ -257,6 +274,9 @@ private fun MessageItem( shape = RoundedCornerShape(8.dp) ) .padding(16.dp) + .clearAndSetSemantics { + contentDescription = messageDescription + } ) { Column( modifier = Modifier.fillMaxWidth() @@ -300,6 +320,8 @@ private fun ReplyButton( enabled: Boolean = true, onClick: () -> Unit ) { + val replyButtonLabel = stringResource(R.string.he_support_reply_button) + Box( modifier = Modifier .fillMaxWidth() @@ -310,7 +332,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 +343,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 +417,8 @@ private fun ReplyBottomSheet( Text( text = stringResource(R.string.he_support_reply_button), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() } ) TextButton( diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt index f313c378d5da..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,26 +6,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.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.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,108 +24,47 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime -import org.wordpress.android.support.common.ui.EmptyConversationsView +import org.wordpress.android.support.common.ui.ConversationsListScreen +import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.util.generateSampleHESupportConversations -import org.wordpress.android.ui.compose.components.MainTopAppBar -import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 @OptIn(ExperimentalMaterial3Api::class) @Composable fun HEConversationsListScreen( snackbarHostState: SnackbarHostState, - conversations: StateFlow>, - isLoadingConversations: StateFlow, + conversations: List, + conversationsState: ConversationsSupportViewModel.ConversationsState, onConversationClick: (SupportConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, onRefresh: () -> Unit ) { - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - MainTopAppBar( - title = stringResource(R.string.he_support_conversations_title), - navigationIcon = NavigationIcons.BackIcon, - onNavigationIconClick = onBackClick, - actions = { - IconButton(onClick = { onCreateNewConversationClick() }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource( - R.string.he_support_new_conversation_content_description - ) - ) - } - } - ) - } - ) { contentPadding -> - ShowConversationsList( - modifier = Modifier.padding(contentPadding), - conversations = conversations, - isLoadingConversations = isLoadingConversations, - onConversationClick = onConversationClick, - onRefresh = onRefresh, - onCreateNewConversationClick = onCreateNewConversationClick - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ShowConversationsList( - modifier: Modifier, - conversations: StateFlow>, - isLoadingConversations: StateFlow, - onConversationClick: (SupportConversation) -> Unit, - onRefresh: () -> Unit, - onCreateNewConversationClick: () -> Unit -) { - val conversationsList by conversations.collectAsState() - val isLoading by isLoadingConversations.collectAsState() val resources = LocalResources.current - - PullToRefreshBox( - isRefreshing = isLoading, + ConversationsListScreen( + title = stringResource(R.string.he_support_conversations_title), + addConversationContentDescription = stringResource(R.string.he_support_new_conversation_content_description), + snackbarHostState = snackbarHostState, + conversations = conversations, + conversationsState = conversationsState, + onBackClick = onBackClick, + onCreateNewConversationClick = onCreateNewConversationClick, onRefresh = onRefresh, - modifier = modifier.fillMaxSize() - ) { - if (conversationsList.isEmpty() && !isLoading) { - EmptyConversationsView( - modifier = Modifier, - onCreateNewConversationClick = onCreateNewConversationClick + conversationListItem = { conversation -> + HEConversationListItem( + conversation = conversation, + resources = resources, + onClick = { onConversationClick(conversation) } ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - 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 +124,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(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -217,15 +142,13 @@ private fun ConversationsScreenPreview() { @Preview(showBackground = true, name = "HE Support Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewDark() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -237,15 +160,13 @@ private fun ConversationsScreenPreviewDark() { @Preview(showBackground = true, name = "HE Support Conversations List - WordPress") @Composable private fun ConversationsScreenWordPressPreview() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = false, isJetpackApp = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, @@ -257,15 +178,13 @@ private fun ConversationsScreenWordPressPreview() { @Preview(showBackground = true, name = "HE Support Conversations List - Dark WordPress", uiMode = UI_MODE_NIGHT_YES) @Composable private fun ConversationsScreenPreviewWordPressDark() { - val sampleConversations = MutableStateFlow(generateSampleHESupportConversations()) - val isLoading = MutableStateFlow(false) val snackbarHostState = remember { SnackbarHostState() } AppThemeM3(isDarkTheme = true, isJetpackApp = false) { HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = sampleConversations.asStateFlow(), - isLoadingConversations = isLoading.asStateFlow(), + conversations = generateSampleHESupportConversations(), + conversationsState = ConversationsSupportViewModel.ConversationsState.Loaded, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HENewTicketScreen.kt index 0de44b65e38a..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 @@ -41,8 +41,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.foundation.text.KeyboardOptions @@ -122,17 +126,22 @@ 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) + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( value = subject, onValueChange = { subject = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = subjectLabel }, placeholder = { Text( text = stringResource(R.string.he_support_subject_placeholder) @@ -147,24 +156,32 @@ 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) + modifier = Modifier + .padding(bottom = 8.dp) + .semantics { heading() } ) OutlinedTextField( value = siteAddress, onValueChange = { siteAddress = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = siteAddressLabel }, placeholder = { Text( text = stringResource(R.string.he_support_site_address_placeholder) ) }, 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) ) @@ -204,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() } ) } @@ -335,7 +354,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/HESupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt index 0e27d22fdb3a..171abf010aa4 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -108,10 +108,12 @@ class HESupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name, ) { composable(route = ConversationScreen.List.name) { + val conversationsState by viewModel.conversationsState.collectAsState() + val conversations by viewModel.conversations.collectAsState() HEConversationsListScreen( snackbarHostState = snackbarHostState, - conversations = viewModel.conversations, - isLoadingConversations = viewModel.isLoadingConversations, + conversations = conversations, + conversationsState = conversationsState, onConversationClick = { conversation -> viewModel.onConversationClick(conversation) }, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index 09371a142ffa..a0c54123a968 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -13,6 +13,7 @@ import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper import javax.inject.Inject @HiltViewModel @@ -20,7 +21,8 @@ class HESupportViewModel @Inject constructor( accountStore: AccountStore, private val heSupportRepository: HESupportRepository, appLogWrapper: AppLogWrapper, -) : ConversationsSupportViewModel(accountStore, appLogWrapper) { + networkUtilsWrapper: NetworkUtilsWrapper, +) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { private val _isSendingMessage = MutableStateFlow(false) val isSendingMessage: StateFlow = _isSendingMessage.asStateFlow() diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/TicketMainContentView.kt index 93a75cb08257..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 @@ -25,6 +25,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -48,11 +51,14 @@ fun TicketMainContentView( .fillMaxWidth() .padding(bottom = 32.dp) ) { + val messageLabel = stringResource(R.string.he_support_message_label) Text( - text = stringResource(R.string.he_support_message_label), + 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( @@ -60,7 +66,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, @@ -75,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( @@ -85,11 +94,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 +119,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 ) } @@ -119,9 +130,13 @@ 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) + Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), @@ -141,7 +156,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 +174,10 @@ fun TicketMainContentView( Switch( checked = includeAppLogs, onCheckedChange = { checked -> onIncludeAppLogsChanged(checked) }, - enabled = enabled + enabled = enabled, + modifier = Modifier.semantics { + contentDescription = includeLogsLabel + } ) } } 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, diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 57ea0456bf20..25c6c1c688ff 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5130,6 +5130,8 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> No conversations yet Start a new conversation to get help with your WordPress site or account. Start conversation + You + Support Bot Send diff --git a/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt index ff35ecce2881..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 @@ -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" @@ -51,11 +55,13 @@ class AIBotSupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = AIBotSupportViewModel( accountStore = accountStore, aiBotSupportRepository = aiBotSupportRepository, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) } 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..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 @@ -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" @@ -52,11 +56,13 @@ class HESupportViewModelTest : BaseUnitTest() { whenever(accountStore.account).thenReturn(accountModel) whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(testAccessToken) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) viewModel = HESupportViewModel( accountStore = accountStore, heSupportRepository = heSupportRepository, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + networkUtilsWrapper = networkUtilsWrapper, ) }