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

The Cc and Bcc fields now start collapsed into small left-aligned link
buttons under the To box, freeing about two field heights of vertical
space for the message body. Tapping a link expands it into the regular
input field and focuses it; a field also expands on its own when it
already carries recipients (reply-all/mailto prefill, resumed drafts)
and never re-collapses once shown, so it cannot vanish mid-edit. The
expansion state lives in the UI via rememberSaveable and survives
rotation. Moving the Bcc field also gives it the medium shape every
sibling field already had.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 16:20:23 -05:00
co-authored by Claude Fable 5
parent f99c2df85f
commit 99ece7b6d7
2 changed files with 112 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(