feat(compose): collapse Cc/Bcc into expandable links under the To field #82
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user