Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ private fun MainShell(
onBack = { tabNav.popBackStack() },
onOpenThread = { username -> tabNav.navigateToDmThread(username) },
onComposeNew = { tabNav.navigateToNewDm() },
// DMs are limited to mutual follows; when the recipient set is
// empty the picker routes to the existing user search so the
// user can go and follow someone.
onFindPeople = { tabNav.navigate(Routes.USER_SEARCH) },
)

// ---- Lists ----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.interlinedlist.android.feature.directmessages.ui

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.directmessages.data.Recipient
import com.interlinedlist.android.feature.directmessages.ui.newmessage.NewMessageScreen
import com.interlinedlist.android.feature.directmessages.ui.newmessage.NewMessageTestTags
import com.interlinedlist.android.feature.directmessages.ui.newmessage.NewMessageUiState
import com.interlinedlist.android.feature.directmessages.ui.newmessage.RecipientRuleCopy
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* The recipient picker must tell three stories apart: still loading, loaded but
* nobody is messageable yet (the mutual-follow rule), and an actual failure.
*/
@RunWith(AndroidJUnit4::class)
class NewMessageScreenTest {

@get:Rule
val composeRule = createComposeRule()

private val adron = Recipient("u1", "adron", "Adron Hall", null)

private fun setScreen(
state: NewMessageUiState,
onFindPeople: () -> Unit = {},
onRetry: () -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
NewMessageScreen(
state = state,
onQueryChange = {},
onBack = {},
onRecipientChosen = {},
onFindPeople = onFindPeople,
onRetry = onRetry,
)
}
}
}

@Test
fun emptyRecipients_explainsTheRule_andOffersAWayOut() {
setScreen(NewMessageUiState(recipients = emptyList(), isLoading = false))

composeRule.onNodeWithTag(NewMessageTestTags.EMPTY).assertIsDisplayed()
composeRule.onNodeWithText(RecipientRuleCopy.TITLE).assertIsDisplayed()
composeRule.onNodeWithText(RecipientRuleCopy.EXPLANATION).assertIsDisplayed()
composeRule.onNodeWithTag(NewMessageTestTags.FIND_PEOPLE).assertIsDisplayed()
composeRule.onNodeWithTag(NewMessageTestTags.LIST).assertDoesNotExist()
composeRule.onNodeWithTag(NewMessageTestTags.ERROR).assertDoesNotExist()
}

@Test
fun findPeople_routesToUserSearch() {
var tapped = false
setScreen(
state = NewMessageUiState(recipients = emptyList(), isLoading = false),
onFindPeople = { tapped = true },
)

composeRule.onNodeWithTag(NewMessageTestTags.FIND_PEOPLE).performClick()

assertThat(tapped).isTrue()
}

@Test
fun recipients_areStillListed() {
setScreen(NewMessageUiState(recipients = listOf(adron), isLoading = false))

composeRule.onNodeWithTag(NewMessageTestTags.LIST).assertIsDisplayed()
composeRule.onNodeWithTag(NewMessageTestTags.row("adron")).assertIsDisplayed()
composeRule.onNodeWithTag(NewMessageTestTags.EMPTY).assertDoesNotExist()
}

@Test
fun loading_showsProgress_notTheEmptyExplanation() {
setScreen(NewMessageUiState(isLoading = true))

composeRule.onNodeWithTag(NewMessageTestTags.PROGRESS).assertIsDisplayed()
composeRule.onNodeWithTag(NewMessageTestTags.EMPTY).assertDoesNotExist()
}

@Test
fun error_showsRetry_notTheEmptyExplanation() {
var retried = false
setScreen(
state = NewMessageUiState(isLoading = false, errorMessage = "No connection."),
onRetry = { retried = true },
)

composeRule.onNodeWithTag(NewMessageTestTags.ERROR).assertIsDisplayed()
composeRule.onNodeWithText(RecipientRuleCopy.TITLE).assertDoesNotExist()
composeRule.onNodeWithTag(NewMessageTestTags.EMPTY).assertDoesNotExist()

composeRule.onNodeWithTag(NewMessageTestTags.RETRY).performClick()

assertThat(retried).isTrue()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ fun NavController.navigateToNewDm() = navigate(DirectMessagesDestinations.NEW_ME
*
* The app wires this into its top-level NavHost (see the module's report for the
* exact snippet). [onBack] pops the current destination; [onOpenThread] lets the
* host decide how threads are pushed.
* host decide how threads are pushed. [onFindPeople] should open the host's user
* search so someone with no messageable contacts can go and follow people.
*/
fun NavGraphBuilder.directMessagesGraph(
onBack: () -> Unit,
onOpenThread: (username: String) -> Unit,
onComposeNew: () -> Unit,
onFindPeople: () -> Unit,
) {
composable(DirectMessagesDestinations.INBOX) {
InboxRoute(
Expand All @@ -64,6 +66,7 @@ fun NavGraphBuilder.directMessagesGraph(
NewMessageRoute(
onBack = onBack,
onRecipientChosen = onOpenThread,
onFindPeople = onFindPeople,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ package com.interlinedlist.android.feature.directmessages.ui.newmessage

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.layout.width
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.PersonAddAlt
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
Expand All @@ -27,6 +33,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
Expand All @@ -39,6 +46,11 @@ object NewMessageTestTags {
const val SEARCH = "dmNewSearch"
const val LIST = "dmNewList"
const val EMPTY = "dmNewEmpty"
const val FIND_PEOPLE = "dmNewFindPeople"
const val NO_MATCHES = "dmNewNoMatches"
const val PROGRESS = "dmNewProgress"
const val ERROR = "dmNewError"
const val RETRY = "dmNewRetry"
const val BACK = "dmNewBack"
fun row(username: String) = "dmNewRow_$username"
}
Expand All @@ -48,6 +60,7 @@ object NewMessageTestTags {
fun NewMessageRoute(
onBack: () -> Unit,
onRecipientChosen: (username: String) -> Unit,
onFindPeople: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NewMessageViewModel = hiltViewModel(),
) {
Expand All @@ -57,6 +70,8 @@ fun NewMessageRoute(
onQueryChange = viewModel::onQueryChange,
onBack = onBack,
onRecipientChosen = { onRecipientChosen(it.username) },
onFindPeople = onFindPeople,
onRetry = viewModel::load,
modifier = modifier,
)
}
Expand All @@ -69,6 +84,8 @@ fun NewMessageScreen(
onQueryChange: (String) -> Unit,
onBack: () -> Unit,
onRecipientChosen: (Recipient) -> Unit,
onFindPeople: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
Expand All @@ -92,20 +109,49 @@ fun NewMessageScreen(
.fillMaxSize()
.padding(padding),
) {
OutlinedTextField(
value = state.query,
onValueChange = onQueryChange,
singleLine = true,
placeholder = { Text("Search people") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.testTag(NewMessageTestTags.SEARCH),
)
if (state.isEmpty) {
EmptyRecipients(modifier = Modifier.fillMaxSize())
} else {
LazyColumn(
// Searching is only meaningful once there is something to search.
if (state.content == NewMessageContent.Recipients ||
state.content == NewMessageContent.NoMatches
) {
OutlinedTextField(
value = state.query,
onValueChange = onQueryChange,
singleLine = true,
placeholder = { Text("Search people") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.testTag(NewMessageTestTags.SEARCH),
)
}
when (state.content) {
NewMessageContent.Loading -> Centered {
CircularProgressIndicator(
modifier = Modifier.testTag(NewMessageTestTags.PROGRESS),
)
}

NewMessageContent.Error -> Centered {
ErrorRecipients(
message = state.errorMessage.orEmpty(),
onRetry = onRetry,
)
}

NewMessageContent.NoRecipients -> Centered {
NoRecipients(onFindPeople = onFindPeople)
}

NewMessageContent.NoMatches -> Centered {
Text(
text = RecipientRuleCopy.NO_MATCHES,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(NewMessageTestTags.NO_MATCHES),
)
}

NewMessageContent.Recipients -> LazyColumn(
modifier = Modifier
.fillMaxSize()
.testTag(NewMessageTestTags.LIST),
Expand All @@ -123,6 +169,19 @@ fun NewMessageScreen(
}
}

/** Fills the remaining space and centres [content]; used by the non-list states. */
@Composable
private fun Centered(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
content()
}
}

@Composable
private fun RecipientRow(
recipient: Recipient,
Expand Down Expand Up @@ -158,21 +217,64 @@ private fun RecipientRow(
}
}

/**
* Loaded, but the user may not message anyone yet. This is the normal state for
* a new account, so it explains the rule (see [RecipientRuleCopy]) and offers a
* way out instead of leaving the user on a blank list.
*/
@Composable
private fun EmptyRecipients(modifier: Modifier = Modifier) {
private fun NoRecipients(onFindPeople: () -> Unit) {
Column(
modifier = modifier.testTag(NewMessageTestTags.EMPTY),
modifier = Modifier.testTag(NewMessageTestTags.EMPTY),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Filled.PersonAddAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = "No one to message yet",
text = RecipientRuleCopy.TITLE,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Follow people to start conversations.",
text = RecipientRuleCopy.EXPLANATION,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(20.dp))
Button(
onClick = onFindPeople,
modifier = Modifier.testTag(NewMessageTestTags.FIND_PEOPLE),
) {
Text(RecipientRuleCopy.FIND_PEOPLE)
}
}
}

/** A genuine failure to load the recipient set — retryable, never the rule copy. */
@Composable
private fun ErrorRecipients(message: String, onRetry: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.testTag(NewMessageTestTags.ERROR),
)
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry, modifier = Modifier.testTag(NewMessageTestTags.RETRY)) {
Text("Retry")
}
}
}
Loading
Loading