diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt index 97777c4..c6079e9 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -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()) } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 3acb89f..4d83df2 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -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, 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(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( diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 2ceff23..f010456 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -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, diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt index da82df2..0eace28 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -153,6 +153,27 @@ class ComposeViewModelTest { assertEquals("

Draft body

", vm.state.value.bodyHtml) } + @Test + fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) { + val mailRepository = mockk(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(relaxed = true)