feat(compose): collapse Cc/Bcc into expandable links under the To field #82

Merged
JMR-dev merged 4 commits from feat-compose-collapsible-cc-bcc into main 2026-07-01 22:29:52 +00:00
4 changed files with 134 additions and 18 deletions
@@ -5,6 +5,8 @@ import android.Manifest
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
@@ -118,4 +120,30 @@ class ComposeScreenTest {
composeTestRule.waitUntil(timeoutMillis = 5_000) { closed }
}
@Test
fun ccAndBcc_startCollapsed_expandViaLinksAndCarryThroughSend() {
val mailRepository = FakeMailRepository()
setContent(mailRepository)
// Collapsed: the Cc/Bcc labels exist only as links, not as editable fields.
editableField(R.string.compose_cc).assertDoesNotExist()
editableField(R.string.compose_bcc).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.compose_cc)).performClick()
editableField(R.string.compose_cc).performTextInput("cc@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_bcc)).performClick()
editableField(R.string.compose_bcc).performTextInput("bcc@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
val sent = mailRepository.sentMessages.single()
assertEquals("cc@example.com", sent.cc)
assertEquals("bcc@example.com", sent.bcc)
}
/** Matches the editable field labelled [labelRes] but not the collapsed Cc/Bcc link buttons. */
private fun editableField(labelRes: Int) = composeTestRule.onNode(hasText(string(labelRes)) and hasSetTextAction())
}
@@ -10,6 +10,7 @@ import android.provider.OpenableColumns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -50,9 +51,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
@@ -152,24 +156,11 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
SuggestionList(state.suggestions, viewModel::pickSuggestion)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.cc,
onValueChange = viewModel::onCcChange,
label = { Text(stringResource(R.string.compose_cc)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.bcc,
onValueChange = viewModel::onBccChange,
label = { Text(stringResource(R.string.compose_bcc)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
CcBccFields(
cc = state.cc,
onCcChange = viewModel::onCcChange,
bcc = state.bcc,
onBccChange = viewModel::onBccChange,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
@@ -245,6 +236,81 @@ private fun FromRow(accounts: List<Account>, selectedId: String?, onSelect: (Str
}
}
/**
* Cc/Bcc start collapsed into link buttons under the To box so the body gets the vertical space.
* A field expands when its link is tapped (taking focus), or by itself once it carries recipients
* (reply-all/mailto prefill, a resumed draft). Once shown, a field never re-collapses — emptying
* it mid-edit must not make it vanish.
*/
@Composable
private fun CcBccFields(cc: String, onCcChange: (String) -> Unit, bcc: String, onBccChange: (String) -> Unit) {
var ccShown by rememberSaveable { mutableStateOf(cc.isNotBlank()) }
var bccShown by rememberSaveable { mutableStateOf(bcc.isNotBlank()) }
LaunchedEffect(cc, bcc) {
if (cc.isNotBlank()) ccShown = true
if (bcc.isNotBlank()) bccShown = true
}
val ccFocus = remember { FocusRequester() }
val bccFocus = remember { FocusRequester() }
// Deliberately not saveable: only a link tap moves focus, never rotation or a loading draft.
var pendingFocus by remember { mutableStateOf<FocusRequester?>(null) }
LaunchedEffect(pendingFocus) {
pendingFocus?.requestFocus()
pendingFocus = null
}
if (ccShown) {
Spacer(Modifier.height(8.dp))
RecipientField(cc, onCcChange, R.string.compose_cc, ccFocus)
}
if (bccShown) {
Spacer(Modifier.height(8.dp))
RecipientField(bcc, onBccChange, R.string.compose_bcc, bccFocus)
}
if (!ccShown || !bccShown) {
Row {
if (!ccShown) {
TextButton(
onClick = {
ccShown = true
pendingFocus = ccFocus
},
) {
Text(stringResource(R.string.compose_cc))
}
}
if (!bccShown) {
TextButton(
onClick = {
bccShown = true
pendingFocus = bccFocus
},
) {
Text(stringResource(R.string.compose_bcc))
}
}
}
}
}
@Composable
private fun RecipientField(
value: String,
onValueChange: (String) -> Unit,
@StringRes labelRes: Int,
focusRequester: FocusRequester,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(stringResource(labelRes)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AttachmentsSection(
@@ -98,6 +98,7 @@ class ComposeViewModel @Inject constructor(
it.copy(
to = draft.to,
cc = draft.cc,
bcc = draft.bcc,
subject = draft.subject,
body = draft.body,
bodyHtml = draft.bodyHtml,
@@ -153,6 +153,27 @@ class ComposeViewModelTest {
assertEquals("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
}
@Test
fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) {
val mailRepository = mockk<MailRepository>(relaxed = true)
coEvery { mailRepository.getDraft("d1") } returns Draft(
id = "d1",
accountId = "imap:a",
to = "x@example.org",
cc = "",
bcc = "hidden@example.org",
subject = "Hi",
body = "Draft body",
updatedAt = 0L,
)
val vm = viewModel(
savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")),
mailRepository = mailRepository,
)
assertEquals("hidden@example.org", vm.state.value.bcc)
}
@Test
fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) {
val mailRepository = mockk<MailRepository>(relaxed = true)