diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 75ceec99e..0b6be5a9f 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -388,11 +388,12 @@
-
+
+ android:launchMode="singleTop"
+ android:theme="@android:style/Theme.Material.Light.NoActionBar">
@@ -404,6 +405,13 @@
+
+
+
+
()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ @Test
+ fun initialState_showsSaveTextButton() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).assertIsDisplayed()
+ }
+
+ @Test
+ fun initialState_showsCloseButton() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.CLOSE_BUTTON).assertIsDisplayed()
+ }
+
+ @Test
+ fun initialState_showsNameField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).assertIsDisplayed()
+ }
+
+ @Test
+ fun initialState_showsPhoneField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.phoneField(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun initialState_showsEmailField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.emailField(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun tapSave_dispatchesSaveAction() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).performClick()
+ assertEquals(ContactCreationAction.Save, capturedActions.last())
+ }
+
+ @Test
+ fun tapClose_dispatchesNavigateBackAction() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.CLOSE_BUTTON).performClick()
+ assertEquals(ContactCreationAction.NavigateBack, capturedActions.last())
+ }
+
+ @Test
+ fun savingState_disablesSaveButton() {
+ setContent(state = ContactCreationUiState(isSaving = true))
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).assertIsNotEnabled()
+ }
+
+ @Test
+ fun notSavingState_enablesSaveButton() {
+ setContent(state = ContactCreationUiState(isSaving = false))
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).assertIsEnabled()
+ }
+
+ // --- Discard dialog ---
+
+ @Test
+ fun discardDialog_rendersWhenShowDiscardDialogTrue() {
+ setContent(state = ContactCreationUiState(showDiscardDialog = true))
+ composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG).assertIsDisplayed()
+ }
+
+ @Test
+ fun discardDialog_notRenderedByDefault() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG).assertDoesNotExist()
+ }
+
+ @Test
+ fun discardDialog_confirmDispatchesConfirmDiscard() {
+ setContent(state = ContactCreationUiState(showDiscardDialog = true))
+ composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG_CONFIRM).performClick()
+ assertEquals(ContactCreationAction.ConfirmDiscard, capturedActions.last())
+ }
+
+ @Test
+ fun discardDialog_dismissDispatchesDismissDiscardDialog() {
+ setContent(state = ContactCreationUiState(showDiscardDialog = true))
+ composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG_DISMISS).performClick()
+ assertEquals(ContactCreationAction.DismissDiscardDialog, capturedActions.last())
+ }
+
+ // --- Add more info chip grid ---
+
+ @Test
+ fun addMoreInfoSection_showsWhenChipsAvailable() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.ADD_MORE_INFO_SECTION).assertIsDisplayed()
+ }
+
+ @Test
+ fun addMoreInfoSection_addressChipAddsAddress() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.addMoreInfoChip("address")).performClick()
+ assertEquals(ContactCreationAction.AddAddress, capturedActions.last())
+ }
+
+ @Test
+ fun addMoreInfoSection_orgChipShowsOrganization() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.addMoreInfoChip("organization")).performClick()
+ assertEquals(ContactCreationAction.ShowOrganization, capturedActions.last())
+ }
+
+ @Test
+ fun addMoreInfoSection_noteChipShowsNote() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.addMoreInfoChip("note")).performClick()
+ assertEquals(ContactCreationAction.ShowNote, capturedActions.last())
+ }
+
+ private fun setContent(state: ContactCreationUiState = ContactCreationUiState()) {
+ composeTestRule.setContent {
+ AppTheme {
+ ContactCreationEditorScreen(
+ uiState = state,
+ accounts = emptyList(),
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/ContactCreationFlowTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/ContactCreationFlowTest.kt
new file mode 100644
index 000000000..eb688b908
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/ContactCreationFlowTest.kt
@@ -0,0 +1,141 @@
+package com.android.contacts.ui.contactcreation
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.core.AppTheme
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * End-to-end flow tests exercising the full [ContactCreationEditorScreen].
+ *
+ * Uses [createAndroidComposeRule] with [ComponentActivity] + the screen composable
+ * directly (avoids Hilt wiring for the Activity). Actions are captured via lambda
+ * to verify the full UI -> action pipeline.
+ */
+class ContactCreationFlowTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ // --- 1. Basic save flow ---
+
+ @Test
+ fun createBasicContact_endToEnd() {
+ setContent()
+ // Type first name
+ composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).performTextInput("John")
+ assertTrue(
+ capturedActions.any { it is ContactCreationAction.UpdateFirstName },
+ )
+
+ // Type phone
+ composeTestRule.onNodeWithTag(TestTags.phoneField(0)).performTextInput("555-0100")
+ assertTrue(
+ capturedActions.any { it is ContactCreationAction.UpdatePhone },
+ )
+
+ // Tap save
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).performClick()
+ assertEquals(ContactCreationAction.Save, capturedActions.last())
+ }
+
+ // --- 2. All fields save flow ---
+
+ @Test
+ fun createWithAllFields_endToEnd() {
+ val state = TestFactory.fullState()
+ setContent(state = state)
+
+ // Verify core sections are rendered
+ composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.phoneField(0)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.emailField(0)).assertIsDisplayed()
+
+ // Tap save
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).performClick()
+ assertEquals(ContactCreationAction.Save, capturedActions.last())
+ }
+
+ // --- 3. Cancel with discard flow ---
+
+ @Test
+ fun cancelWithDiscard_endToEnd() {
+ setContent(state = ContactCreationUiState(showDiscardDialog = true))
+
+ // Discard dialog should be visible
+ composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG).assertIsDisplayed()
+
+ // Tap discard (confirm button)
+ composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG_CONFIRM).performClick()
+ assertEquals(ContactCreationAction.ConfirmDiscard, capturedActions.last())
+ }
+
+ // --- 4. Intent extras pre-fill ---
+
+ @Test
+ fun intentExtras_preFill_endToEnd() {
+ // Simulate pre-filled state (as Activity.applyIntentExtras would produce)
+ val preFilled = ContactCreationUiState(
+ nameState = NameState(first = "Jane"),
+ phoneNumbers = listOf(PhoneFieldState(id = "p1", number = "555-1234")),
+ )
+ setContent(state = preFilled)
+
+ // Fields should be displayed with pre-filled data
+ composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.phoneField(0)).assertIsDisplayed()
+
+ // Save
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).performClick()
+ assertEquals(ContactCreationAction.Save, capturedActions.last())
+ }
+
+ // --- 5. Zero-account local contact ---
+
+ @Test
+ fun zeroAccount_localContact_endToEnd() {
+ val state = ContactCreationUiState(
+ selectedAccount = null,
+ accountName = null,
+ )
+ setContent(state = state)
+
+ // Type a name and save
+ composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).performTextInput("Local")
+ composeTestRule.onNodeWithTag(TestTags.SAVE_TEXT_BUTTON).performClick()
+ assertEquals(ContactCreationAction.Save, capturedActions.last())
+ }
+
+ // --- Helper ---
+
+ private fun setContent(state: ContactCreationUiState = ContactCreationUiState()) {
+ composeTestRule.setContent {
+ AppTheme {
+ ContactCreationEditorScreen(
+ uiState = state,
+ accounts = emptyList(),
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/TestFactory.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/TestFactory.kt
new file mode 100644
index 000000000..f6bf761a1
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/TestFactory.kt
@@ -0,0 +1,68 @@
+package com.android.contacts.ui.contactcreation
+
+import android.net.Uri
+import com.android.contacts.ui.contactcreation.component.AddressType
+import com.android.contacts.ui.contactcreation.component.EmailType
+import com.android.contacts.ui.contactcreation.component.EventType
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.component.RelationType
+import com.android.contacts.ui.contactcreation.component.WebsiteType
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.contactcreation.model.EventFieldState
+import com.android.contacts.ui.contactcreation.model.GroupFieldState
+import com.android.contacts.ui.contactcreation.model.ImFieldState
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.contactcreation.model.RelationFieldState
+import com.android.contacts.ui.contactcreation.model.WebsiteFieldState
+
+internal object TestFactory {
+
+ fun fullState() = ContactCreationUiState(
+ nameState = NameState(first = "Jane", last = "Doe"),
+ phoneNumbers = listOf(
+ PhoneFieldState(id = "phone-1", number = "555-1234", type = PhoneType.Mobile)
+ ),
+ emails = listOf(
+ EmailFieldState(id = "email-1", address = "test@example.com", type = EmailType.Home)
+ ),
+ addresses = listOf(
+ AddressFieldState(
+ id = "addr-1",
+ street = "123 Main St",
+ city = "Springfield",
+ type = AddressType.Home,
+ ),
+ ),
+ organization = OrganizationFieldState(company = "Acme Corp", title = "Engineer"),
+ events = listOf(
+ EventFieldState(id = "event-1", startDate = "1990-01-15", type = EventType.Birthday)
+ ),
+ relations = listOf(
+ RelationFieldState(id = "rel-1", name = "Jane Doe", type = RelationType.Spouse)
+ ),
+ imAccounts = listOf(
+ ImFieldState(id = "im-1", data = "user@jabber", protocol = ImProtocol.Jabber)
+ ),
+ websites = listOf(
+ WebsiteFieldState(
+ id = "web-1",
+ url = "https://example.com",
+ type = WebsiteType.Homepage
+ )
+ ),
+ note = "Important note",
+ nickname = "JD",
+ sipAddress = "sip:jane@voip.example.com",
+ groups = listOf(GroupFieldState(groupId = 1L, title = "Friends")),
+ photoUri = Uri.parse("content://media/external/images/99"),
+ showOrganization = true,
+ showNote = true,
+ showNickname = true,
+ showSipAddress = true,
+ )
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/AddressSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/AddressSectionTest.kt
new file mode 100644
index 000000000..6f9e4f313
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/AddressSectionTest.kt
@@ -0,0 +1,99 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.core.AppTheme
+import kotlin.test.assertIs
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class AddressSectionTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ @Test
+ fun rendersAddAddressButton() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.ADDRESS_ADD).assertIsDisplayed()
+ }
+
+ @Test
+ fun tapAddAddress_dispatchesAddAddressAction() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.ADDRESS_ADD).performClick()
+ assertEquals(ContactCreationAction.AddAddress, capturedActions.last())
+ }
+
+ @Test
+ fun rendersAllAddressSubFields() {
+ val addresses = listOf(AddressFieldState(id = "1"))
+ setContent(addresses = addresses)
+ composeTestRule.onNodeWithTag(TestTags.addressStreet(0)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.addressCity(0)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.addressRegion(0)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.addressPostcode(0)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.addressCountry(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun typeInStreet_dispatchesUpdateAddressStreet() {
+ val addresses = listOf(AddressFieldState(id = "1"))
+ setContent(addresses = addresses)
+ composeTestRule.onNodeWithTag(TestTags.addressStreet(0)).performTextInput("123 Main")
+ assertIs(capturedActions.last())
+ }
+
+ @Test
+ fun typeInCity_dispatchesUpdateAddressCity() {
+ val addresses = listOf(AddressFieldState(id = "1"))
+ setContent(addresses = addresses)
+ composeTestRule.onNodeWithTag(TestTags.addressCity(0)).performTextInput("Chicago")
+ assertIs(capturedActions.last())
+ }
+
+ @Test
+ fun rendersAddressTypeSelector() {
+ val addresses = listOf(AddressFieldState(id = "1"))
+ setContent(addresses = addresses)
+ composeTestRule.onNodeWithTag(TestTags.addressType(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun selectAddressType_dispatchesUpdateAddressType() {
+ val addresses = listOf(AddressFieldState(id = "1", type = AddressType.Home))
+ setContent(addresses = addresses)
+ val workLabel = composeTestRule.activity.getString(R.string.field_type_work)
+ composeTestRule.onNodeWithTag(TestTags.addressType(0)).performClick()
+ composeTestRule.onNodeWithTag(TestTags.fieldTypeOption(workLabel)).performClick()
+ assertIs(capturedActions.last())
+ }
+
+ private fun setContent(addresses: List = emptyList()) {
+ composeTestRule.setContent {
+ AppTheme {
+ AddressSectionContent(
+ addresses = addresses,
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/CustomLabelDialogTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/CustomLabelDialogTest.kt
new file mode 100644
index 000000000..90c1e6c4f
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/CustomLabelDialogTest.kt
@@ -0,0 +1,78 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.core.AppTheme
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class CustomLabelDialogTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private var confirmedLabel: String? = null
+ private var dismissed = false
+
+ @Before
+ fun setup() {
+ confirmedLabel = null
+ dismissed = false
+ }
+
+ @Test
+ fun showsInputField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.CUSTOM_LABEL_INPUT).assertIsDisplayed()
+ }
+
+ @Test
+ fun confirmWithLabel_dispatchesLabel() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.CUSTOM_LABEL_INPUT).performTextInput("Work cell")
+ composeTestRule.onNodeWithTag(TestTags.CUSTOM_LABEL_OK).performClick()
+ assertEquals("Work cell", confirmedLabel)
+ }
+
+ @Test
+ fun cancelDismisses() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.CUSTOM_LABEL_CANCEL).performClick()
+ assertTrue(dismissed)
+ }
+
+ @Test
+ fun emptyLabel_disablesConfirm() {
+ setContent()
+ // Don't type anything — confirm should be disabled
+ composeTestRule.onNodeWithTag(TestTags.CUSTOM_LABEL_OK).assertIsNotEnabled()
+ }
+
+ @Test
+ fun nonEmptyLabel_enablesConfirm() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.CUSTOM_LABEL_INPUT).performTextInput("Label")
+ composeTestRule.onNodeWithTag(TestTags.CUSTOM_LABEL_OK).assertIsEnabled()
+ }
+
+ private fun setContent() {
+ composeTestRule.setContent {
+ AppTheme {
+ CustomLabelDialog(
+ onConfirm = { confirmedLabel = it },
+ onDismiss = { dismissed = true },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/EmailSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/EmailSectionTest.kt
new file mode 100644
index 000000000..98c12bfa2
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/EmailSectionTest.kt
@@ -0,0 +1,84 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.core.AppTheme
+import kotlin.test.assertIs
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class EmailSectionTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ @Test
+ fun rendersEmailField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.emailField(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun rendersAddEmailButton() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.EMAIL_ADD).assertIsDisplayed()
+ }
+
+ @Test
+ fun typeInEmail_dispatchesUpdateEmail() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.emailField(0)).performTextInput("a@b.com")
+ assertIs(capturedActions.last())
+ }
+
+ @Test
+ fun tapAddEmail_dispatchesAddEmailAction() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.EMAIL_ADD).performClick()
+ assertEquals(ContactCreationAction.AddEmail, capturedActions.last())
+ }
+
+ @Test
+ fun rendersEmailTypeSelector() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.emailType(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun selectEmailType_dispatchesUpdateEmailType() {
+ val email = EmailFieldState(id = "1", address = "a@b.com", type = EmailType.Home)
+ setContent(emails = listOf(email))
+ val workLabel = composeTestRule.activity.getString(R.string.field_type_work)
+ composeTestRule.onNodeWithTag(TestTags.emailType(0)).performClick()
+ composeTestRule.onNodeWithTag(TestTags.fieldTypeOption(workLabel)).performClick()
+ assertIs(capturedActions.last())
+ }
+
+ private fun setContent(emails: List = listOf(EmailFieldState())) {
+ composeTestRule.setContent {
+ AppTheme {
+ EmailSectionContent(
+ emails = emails,
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/FieldTypeSelectorTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/FieldTypeSelectorTest.kt
new file mode 100644
index 000000000..22fd72c18
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/FieldTypeSelectorTest.kt
@@ -0,0 +1,82 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.core.AppTheme
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class FieldTypeSelectorTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private var selectedType: String? = null
+ private val types = listOf("Mobile", "Home", "Work", "Other")
+
+ @Before
+ fun setup() {
+ selectedType = null
+ }
+
+ @Test
+ fun showsCurrentTypeLabel() {
+ setContent(currentType = "Mobile")
+ composeTestRule.onNodeWithTag(SELECTOR_TAG).assertIsDisplayed()
+ }
+
+ @Test
+ fun tapOpensDropdown() {
+ setContent(currentType = "Mobile")
+ composeTestRule.onNodeWithTag(SELECTOR_TAG).performClick()
+ composeTestRule.onNodeWithTag(TestTags.fieldTypeOption("Home")).assertIsDisplayed()
+ }
+
+ @Test
+ fun selectType_dispatchesCallback() {
+ setContent(currentType = "Mobile")
+ composeTestRule.onNodeWithTag(SELECTOR_TAG).performClick()
+ composeTestRule.onNodeWithTag(TestTags.fieldTypeOption("Work")).performClick()
+ assertEquals("Work", selectedType)
+ }
+
+ @Test
+ fun menuItemsMatchTypeList() {
+ setContent(currentType = "Mobile")
+ composeTestRule.onNodeWithTag(SELECTOR_TAG).performClick()
+ types.forEach { type ->
+ composeTestRule.onNodeWithTag(TestTags.fieldTypeOption(type)).assertIsDisplayed()
+ }
+ }
+
+ @Test
+ fun chipHasTestTag() {
+ setContent(currentType = "Home")
+ composeTestRule.onNodeWithTag(SELECTOR_TAG).assertExists()
+ }
+
+ private fun setContent(currentType: String) {
+ composeTestRule.setContent {
+ AppTheme {
+ FieldTypeSelector(
+ currentLabel = currentType,
+ labels = types,
+ onIndexSelected = { selectedType = types[it] },
+ modifier = Modifier.testTag(SELECTOR_TAG),
+ )
+ }
+ }
+ }
+
+ companion object {
+ private const val SELECTOR_TAG = "test_field_type_selector"
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/GroupSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/GroupSectionTest.kt
new file mode 100644
index 000000000..6e96a1bde
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/GroupSectionTest.kt
@@ -0,0 +1,103 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.GroupFieldState
+import com.android.contacts.ui.contactcreation.model.GroupInfo
+import com.android.contacts.ui.core.AppTheme
+import kotlin.test.assertIs
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class GroupSectionTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ @Test
+ fun noAvailableGroups_sectionNotShown() {
+ setContent(availableGroups = emptyList())
+ composeTestRule.onNodeWithTag(TestTags.GROUP_SECTION).assertDoesNotExist()
+ }
+
+ @Test
+ fun availableGroups_showsGroupSection() {
+ setContent(
+ availableGroups = listOf(GroupInfo(groupId = 1L, title = "Friends")),
+ )
+ composeTestRule.onNodeWithTag(TestTags.GROUP_SECTION).assertIsDisplayed()
+ }
+
+ @Test
+ fun rendersCheckboxForEachGroup() {
+ setContent(
+ availableGroups = listOf(
+ GroupInfo(groupId = 1L, title = "Friends"),
+ GroupInfo(groupId = 2L, title = "Family"),
+ ),
+ )
+ composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.groupCheckbox(1)).assertIsDisplayed()
+ }
+
+ @Test
+ fun selectedGroup_showsChecked() {
+ setContent(
+ availableGroups = listOf(GroupInfo(groupId = 1L, title = "Friends")),
+ selectedGroups = listOf(GroupFieldState(groupId = 1L, title = "Friends")),
+ )
+ composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).assertIsOn()
+ }
+
+ @Test
+ fun unselectedGroup_showsUnchecked() {
+ setContent(
+ availableGroups = listOf(GroupInfo(groupId = 1L, title = "Friends")),
+ selectedGroups = emptyList(),
+ )
+ composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).assertIsOff()
+ }
+
+ @Test
+ fun tapCheckbox_dispatchesToggleGroupAction() {
+ setContent(
+ availableGroups = listOf(GroupInfo(groupId = 42L, title = "Friends")),
+ )
+ composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).performClick()
+ val action = capturedActions.last()
+ assertIs(action)
+ assertEquals(42L, action.groupId)
+ assertEquals("Friends", action.title)
+ }
+
+ private fun setContent(
+ availableGroups: List = emptyList(),
+ selectedGroups: List = emptyList(),
+ ) {
+ composeTestRule.setContent {
+ AppTheme {
+ GroupSectionContent(
+ availableGroups = availableGroups,
+ selectedGroups = selectedGroups,
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/NameSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/NameSectionTest.kt
new file mode 100644
index 000000000..935657b91
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/NameSectionTest.kt
@@ -0,0 +1,65 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTextInput
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.core.AppTheme
+import kotlin.test.assertIs
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class NameSectionTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ @Test
+ fun rendersFirstNameField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).assertIsDisplayed()
+ }
+
+ @Test
+ fun rendersLastNameField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.NAME_LAST).assertIsDisplayed()
+ }
+
+ @Test
+ fun typeFirstName_dispatchesUpdateFirstName() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).performTextInput("John")
+ assertIs(capturedActions.last())
+ }
+
+ @Test
+ fun typeLastName_dispatchesUpdateLastName() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.NAME_LAST).performTextInput("Doe")
+ assertIs(capturedActions.last())
+ }
+
+ private fun setContent(nameState: NameState = NameState()) {
+ composeTestRule.setContent {
+ AppTheme {
+ NameSectionContent(
+ nameState = nameState,
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/OrganizationSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/OrganizationSectionTest.kt
new file mode 100644
index 000000000..d00a69911
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/OrganizationSectionTest.kt
@@ -0,0 +1,80 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTextInput
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+import com.android.contacts.ui.core.AppTheme
+import kotlin.test.assertIs
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class OrganizationSectionTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ @Test
+ fun rendersCompanyAndTitleFields() {
+ setContent(OrganizationFieldState())
+ composeTestRule.onNodeWithTag(TestTags.ORG_COMPANY).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.ORG_TITLE).assertIsDisplayed()
+ }
+
+ @Test
+ fun typeInCompany_dispatchesUpdateCompany() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.ORG_COMPANY).performTextInput("Acme")
+ assertIs(capturedActions.last())
+ assertEquals(
+ "Acme",
+ (capturedActions.last() as ContactCreationAction.UpdateCompany).value,
+ )
+ }
+
+ @Test
+ fun typeInTitle_dispatchesUpdateJobTitle() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.ORG_TITLE).performTextInput("CTO")
+ assertIs(capturedActions.last())
+ assertEquals(
+ "CTO",
+ (capturedActions.last() as ContactCreationAction.UpdateJobTitle).value,
+ )
+ }
+
+ @Test
+ fun preFilledState_rendersValues() {
+ setContent(
+ OrganizationFieldState(company = "Google", title = "SWE"),
+ )
+ composeTestRule.onNodeWithTag(TestTags.ORG_COMPANY).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.ORG_TITLE).assertIsDisplayed()
+ }
+
+ private fun setContent(
+ organization: OrganizationFieldState = OrganizationFieldState(),
+ ) {
+ composeTestRule.setContent {
+ AppTheme {
+ OrganizationSectionContent(
+ organization = organization,
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhoneSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhoneSectionTest.kt
new file mode 100644
index 000000000..e15c1de4f
--- /dev/null
+++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhoneSectionTest.kt
@@ -0,0 +1,93 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.core.AppTheme
+import kotlin.test.assertIs
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class PhoneSectionTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private val capturedActions = mutableListOf()
+
+ @Before
+ fun setup() {
+ capturedActions.clear()
+ }
+
+ @Test
+ fun rendersPhoneField() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.phoneField(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun rendersAddPhoneButton() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.PHONE_ADD).assertIsDisplayed()
+ }
+
+ @Test
+ fun typeInPhone_dispatchesUpdatePhone() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.phoneField(0)).performTextInput("555")
+ assertIs(capturedActions.last())
+ }
+
+ @Test
+ fun tapAddPhone_dispatchesAddPhoneAction() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.PHONE_ADD).performClick()
+ assertEquals(ContactCreationAction.AddPhone, capturedActions.last())
+ }
+
+ @Test
+ fun rendersPhoneTypeSelector() {
+ setContent()
+ composeTestRule.onNodeWithTag(TestTags.phoneType(0)).assertIsDisplayed()
+ }
+
+ @Test
+ fun tapPhoneType_showsDropdownMenu() {
+ val phone = PhoneFieldState(id = "1", number = "555", type = PhoneType.Mobile)
+ setContent(phones = listOf(phone))
+ val homeLabel = composeTestRule.activity.getString(R.string.field_type_home)
+ composeTestRule.onNodeWithTag(TestTags.phoneType(0)).performClick()
+ composeTestRule.onNodeWithTag(TestTags.fieldTypeOption(homeLabel)).assertIsDisplayed()
+ }
+
+ @Test
+ fun selectPhoneType_dispatchesUpdatePhoneType() {
+ val phone = PhoneFieldState(id = "1", number = "555", type = PhoneType.Mobile)
+ setContent(phones = listOf(phone))
+ val homeLabel = composeTestRule.activity.getString(R.string.field_type_home)
+ composeTestRule.onNodeWithTag(TestTags.phoneType(0)).performClick()
+ composeTestRule.onNodeWithTag(TestTags.fieldTypeOption(homeLabel)).performClick()
+ assertIs(capturedActions.last())
+ }
+
+ private fun setContent(phones: List = listOf(PhoneFieldState())) {
+ composeTestRule.setContent {
+ AppTheme {
+ PhoneSectionContent(
+ phones = phones,
+ onAction = { capturedActions.add(it) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/com/android/contacts/test/MainDispatcherRule.kt b/app/src/test/java/com/android/contacts/test/MainDispatcherRule.kt
new file mode 100644
index 000000000..226d83fa9
--- /dev/null
+++ b/app/src/test/java/com/android/contacts/test/MainDispatcherRule.kt
@@ -0,0 +1,22 @@
+package com.android.contacts.test
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainDispatcherRule(val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) :
+ TestWatcher() {
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
diff --git a/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationIntegrationTest.kt b/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationIntegrationTest.kt
new file mode 100644
index 000000000..537bfb9a7
--- /dev/null
+++ b/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationIntegrationTest.kt
@@ -0,0 +1,278 @@
+package com.android.contacts.ui.contactcreation
+
+import android.net.Uri
+import android.provider.ContactsContract.CommonDataKinds.Email
+import android.provider.ContactsContract.CommonDataKinds.Event
+import android.provider.ContactsContract.CommonDataKinds.Im
+import android.provider.ContactsContract.CommonDataKinds.Nickname
+import android.provider.ContactsContract.CommonDataKinds.Note
+import android.provider.ContactsContract.CommonDataKinds.Organization
+import android.provider.ContactsContract.CommonDataKinds.Phone
+import android.provider.ContactsContract.CommonDataKinds.Relation
+import android.provider.ContactsContract.CommonDataKinds.SipAddress
+import android.provider.ContactsContract.CommonDataKinds.StructuredName
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal
+import android.provider.ContactsContract.CommonDataKinds.Website
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import com.android.contacts.test.MainDispatcherRule
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.mapper.RawContactDeltaMapper
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.ContactCreationEffect
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import kotlin.test.assertIs
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+/**
+ * Integration tests using real [ContactCreationViewModel] + real [RawContactDeltaMapper].
+ * No mocks except appContext via Robolectric.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ContactCreationIntegrationTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ // --- 1. Basic contact produces correct delta ---
+
+ @Test
+ fun createBasicContact_producesCorrectDelta() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+ val phoneId = vm.uiState.value.phoneNumbers.first().id
+ vm.onAction(ContactCreationAction.UpdatePhone(phoneId, "555-0100"))
+ val emailId = vm.uiState.value.emails.first().id
+ vm.onAction(ContactCreationAction.UpdateEmail(emailId, "john@test.com"))
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val delta = effect.result.state[0]
+ assertNotNull(delta.getMimeEntries(StructuredName.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Phone.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Email.CONTENT_ITEM_TYPE))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- 2. All fields produce all MIME types ---
+
+ @Test
+ fun createAllFields_producesAllMimeTypes() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel(initialState = TestFactory.fullState())
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val delta = effect.result.state[0]
+ assertNotNull(delta.getMimeEntries(StructuredName.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Phone.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Email.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Organization.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Event.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Relation.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Im.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Website.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Note.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(Nickname.CONTENT_ITEM_TYPE))
+ assertNotNull(delta.getMimeEntries(SipAddress.CONTENT_ITEM_TYPE))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- 3. Custom phone type produces TYPE_CUSTOM and LABEL ---
+
+ @Test
+ fun customPhoneType_deltaHasTypeCustomAndLabel() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("Test"))
+ val phoneId = vm.uiState.value.phoneNumbers.first().id
+ vm.onAction(ContactCreationAction.UpdatePhone(phoneId, "555-0001"))
+ vm.onAction(
+ ContactCreationAction.UpdatePhoneType(
+ phoneId,
+ PhoneType.Custom("Work cell"),
+ ),
+ )
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val delta = effect.result.state[0]
+ val phoneEntries = delta.getMimeEntries(Phone.CONTENT_ITEM_TYPE)!!
+ assertEquals(Phone.TYPE_CUSTOM, phoneEntries[0].getAsInteger(Phone.TYPE))
+ assertEquals("Work cell", phoneEntries[0].getAsString(Phone.LABEL))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- 5. Process death round-trip delta matches ---
+
+ @Test
+ fun processDeathRoundTrip_deltaMatchesOriginal() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ // Build a ViewModel, fill it, capture state
+ val vm1 = createViewModel()
+ vm1.onAction(ContactCreationAction.UpdateFirstName("Saved"))
+ val phoneId = vm1.uiState.value.phoneNumbers.first().id
+ vm1.onAction(ContactCreationAction.UpdatePhone(phoneId, "555-9999"))
+ val stateAfterFill = vm1.uiState.value
+
+ // Simulate process death: create new VM with the saved state
+ val vm2 = createViewModel(initialState = stateAfterFill)
+
+ vm2.effects.test {
+ vm2.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val delta = effect.result.state[0]
+ val nameEntries = delta.getMimeEntries(StructuredName.CONTENT_ITEM_TYPE)!!
+ assertEquals("Saved", nameEntries[0].getAsString(StructuredName.GIVEN_NAME))
+ val phoneEntries = delta.getMimeEntries(Phone.CONTENT_ITEM_TYPE)!!
+ assertEquals("555-9999", phoneEntries[0].getAsString(Phone.NUMBER))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- 6. Photo URI in updatedPhotos bundle ---
+
+ @Test
+ fun photoUri_inUpdatedPhotosBundle() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("Photo"))
+ val photoUri = Uri.parse("content://media/external/images/42")
+ vm.onAction(ContactCreationAction.SetPhoto(photoUri))
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val photos = effect.result.updatedPhotos
+ assertTrue(photos.size() > 0)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- 7. Multiple phones produce multiple entries ---
+
+ @Test
+ fun multiplePhones_produceMultipleEntries() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("Multi"))
+ val phoneId1 = vm.uiState.value.phoneNumbers.first().id
+ vm.onAction(ContactCreationAction.UpdatePhone(phoneId1, "111"))
+ vm.onAction(ContactCreationAction.AddPhone)
+ val phoneId2 = vm.uiState.value.phoneNumbers[1].id
+ vm.onAction(ContactCreationAction.UpdatePhone(phoneId2, "222"))
+ vm.onAction(ContactCreationAction.AddPhone)
+ val phoneId3 = vm.uiState.value.phoneNumbers[2].id
+ vm.onAction(ContactCreationAction.UpdatePhone(phoneId3, "333"))
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val delta = effect.result.state[0]
+ val phoneEntries = delta.getMimeEntries(Phone.CONTENT_ITEM_TYPE)!!
+ assertEquals(3, phoneEntries.size)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- 8. IM protocol uses PROTOCOL column not TYPE ---
+
+ @Test
+ fun imProtocol_usesProtocolNotType() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("IM"))
+ vm.onAction(ContactCreationAction.AddIm)
+ val imId = vm.uiState.value.imAccounts.first().id
+ vm.onAction(ContactCreationAction.UpdateIm(imId, "user@xmpp"))
+ vm.onAction(
+ ContactCreationAction.UpdateImProtocol(imId, ImProtocol.Jabber),
+ )
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val delta = effect.result.state[0]
+ val imEntries = delta.getMimeEntries(Im.CONTENT_ITEM_TYPE)!!
+ assertEquals(Im.PROTOCOL_JABBER, imEntries[0].getAsInteger(Im.PROTOCOL))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- 9. Address with partial fill still included ---
+
+ @Test
+ fun addressPartialFill_included() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("Addr"))
+ vm.onAction(ContactCreationAction.AddAddress)
+ val addrId = vm.uiState.value.addresses.first().id
+ vm.onAction(ContactCreationAction.UpdateAddressCity(addrId, "Portland"))
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+
+ val delta = effect.result.state[0]
+ val addrEntries = delta.getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)!!
+ assertEquals(1, addrEntries.size)
+ assertEquals(
+ "Portland",
+ addrEntries[0].getAsString(StructuredPostal.CITY),
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- Helper ---
+
+ private fun createViewModel(
+ initialState: ContactCreationUiState = ContactCreationUiState(),
+ ): ContactCreationViewModel {
+ val savedStateHandle = SavedStateHandle(
+ mapOf(ContactCreationViewModel.STATE_KEY to initialState),
+ )
+ return ContactCreationViewModel(
+ savedStateHandle = savedStateHandle,
+ deltaMapper = RawContactDeltaMapper(),
+ accountTypeManager = com.android.contacts.model.AccountTypeManager.getInstance(
+ RuntimeEnvironment.getApplication(),
+ ),
+ defaultDispatcher = mainDispatcherRule.testDispatcher,
+ appContext = RuntimeEnvironment.getApplication(),
+ )
+ }
+}
diff --git a/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationViewModelTest.kt b/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationViewModelTest.kt
new file mode 100644
index 000000000..02f9185b3
--- /dev/null
+++ b/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationViewModelTest.kt
@@ -0,0 +1,529 @@
+package com.android.contacts.ui.contactcreation
+
+import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import com.android.contacts.model.RawContactDelta
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.test.MainDispatcherRule
+import com.android.contacts.ui.contactcreation.mapper.RawContactDeltaMapper
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.ContactCreationEffect
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.contactcreation.model.EventFieldState
+import com.android.contacts.ui.contactcreation.model.ImFieldState
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.contactcreation.model.RelationFieldState
+import com.android.contacts.ui.contactcreation.model.WebsiteFieldState
+import kotlin.test.assertIs
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class ContactCreationViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Test
+ fun initialState_isDefault() {
+ val vm = createViewModel()
+ val state = vm.uiState.value
+ assertEquals(NameState(), state.nameState)
+ assertEquals(1, state.phoneNumbers.size)
+ assertEquals(1, state.emails.size)
+ assertFalse(state.isSaving)
+ }
+
+ @Test
+ fun updateFirstName_updatesState() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+ assertEquals("John", vm.uiState.value.nameState.first)
+ }
+
+ @Test
+ fun updateLastName_updatesState() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateLastName("Doe"))
+ assertEquals("Doe", vm.uiState.value.nameState.last)
+ }
+
+ @Test
+ fun addPhone_addsRow() {
+ val vm = createViewModel()
+ val initialCount = vm.uiState.value.phoneNumbers.size
+ vm.onAction(ContactCreationAction.AddPhone)
+ assertEquals(initialCount + 1, vm.uiState.value.phoneNumbers.size)
+ }
+
+ @Test
+ fun removePhone_removesRow() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.AddPhone)
+ val id = vm.uiState.value.phoneNumbers[0].id
+ vm.onAction(ContactCreationAction.RemovePhone(id))
+ assertEquals(1, vm.uiState.value.phoneNumbers.size)
+ assertTrue(vm.uiState.value.phoneNumbers.none { it.id == id })
+ }
+
+ @Test
+ fun updatePhone_updatesValue() {
+ val vm = createViewModel()
+ val id = vm.uiState.value.phoneNumbers[0].id
+ vm.onAction(ContactCreationAction.UpdatePhone(id, "555-1234"))
+ assertEquals("555-1234", vm.uiState.value.phoneNumbers[0].number)
+ }
+
+ @Test
+ fun addEmail_addsRow() {
+ val vm = createViewModel()
+ val initialCount = vm.uiState.value.emails.size
+ vm.onAction(ContactCreationAction.AddEmail)
+ assertEquals(initialCount + 1, vm.uiState.value.emails.size)
+ }
+
+ @Test
+ fun saveAction_withPendingChanges_emitsSaveEffect() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ assertIs(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun saveAction_withNoChanges_doesNotEmitSaveEffect() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun navigateBack_withNoChanges_emitsNavigateBack() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.NavigateBack)
+ assertIs(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun navigateBack_withChanges_setsShowDiscardDialog() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+ vm.onAction(ContactCreationAction.NavigateBack)
+ assertTrue(vm.uiState.value.showDiscardDialog)
+ }
+
+ @Test
+ fun navigateBack_withChanges_doesNotEmitNavigateBack() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.NavigateBack)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun dismissDiscardDialog_clearsShowDiscardDialog() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+ vm.onAction(ContactCreationAction.NavigateBack)
+ assertTrue(vm.uiState.value.showDiscardDialog)
+
+ vm.onAction(ContactCreationAction.DismissDiscardDialog)
+ assertFalse(vm.uiState.value.showDiscardDialog)
+ }
+
+ @Test
+ fun confirmDiscard_emitsNavigateBack() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+ vm.onAction(ContactCreationAction.NavigateBack)
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.ConfirmDiscard)
+ assertIs(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun confirmDiscard_clearsShowDiscardDialog() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+ vm.onAction(ContactCreationAction.NavigateBack)
+ assertTrue(vm.uiState.value.showDiscardDialog)
+
+ vm.onAction(ContactCreationAction.ConfirmDiscard)
+ assertFalse(vm.uiState.value.showDiscardDialog)
+ }
+
+ // --- Zero-account / local-only ---
+
+ @Test
+ fun save_withNoAccount_usesLocalAccount() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("Local"))
+
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ val effect = awaitItem()
+ assertIs(effect)
+ val delta = effect.result.state[0]
+ assertIs(delta)
+ // When no account selected, mapper calls setAccountToLocal()
+ assertNull(vm.uiState.value.selectedAccount)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onSaveResult_success_emitsSaveSuccess() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ val uri = Uri.parse("content://contacts/1")
+
+ vm.effects.test {
+ vm.onSaveResult(true, uri)
+ val effect = awaitItem()
+ assertIs(effect)
+ assertEquals(uri, effect.contactUri)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun onSaveResult_failure_emitsShowError() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+
+ vm.effects.test {
+ vm.onSaveResult(false, null)
+ assertIs(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun processDeathRestore_preservesState() {
+ val savedState = ContactCreationUiState(
+ nameState = NameState(first = "Saved"),
+ phoneNumbers = listOf(PhoneFieldState(number = "555")),
+ )
+ val vm = createViewModel(initialState = savedState)
+ assertEquals("Saved", vm.uiState.value.nameState.first)
+ assertEquals("555", vm.uiState.value.phoneNumbers[0].number)
+ }
+
+ // --- Photo ---
+
+ @Test
+ fun setPhoto_updatesPhotoUri() {
+ val vm = createViewModel()
+ val uri = Uri.parse("content://media/external/images/1234")
+ vm.onAction(ContactCreationAction.SetPhoto(uri))
+ assertEquals(uri, vm.uiState.value.photoUri)
+ }
+
+ @Test
+ fun removePhoto_clearsPhotoUri() {
+ val vm = createViewModel()
+ val uri = Uri.parse("content://media/external/images/1234")
+ vm.onAction(ContactCreationAction.SetPhoto(uri))
+ vm.onAction(ContactCreationAction.RemovePhoto)
+ assertNull(vm.uiState.value.photoUri)
+ }
+
+ @Test
+ fun setPhoto_countsAsPendingChange() {
+ val vm = createViewModel()
+ val uri = Uri.parse("content://media/external/images/1234")
+ vm.onAction(ContactCreationAction.SetPhoto(uri))
+ assertTrue(vm.uiState.value.hasPendingChanges())
+ }
+
+ @Test
+ fun saveAction_setsIsSaving() =
+ runTest(mainDispatcherRule.testDispatcher) {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateFirstName("John"))
+ vm.effects.test {
+ vm.onAction(ContactCreationAction.Save)
+ awaitItem() // Save effect
+ assertTrue(vm.uiState.value.isSaving)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- Process death round-trip ---
+
+ @Test
+ fun processDeathRestore_preservesAllFieldTypes() {
+ val savedState = ContactCreationUiState(
+ nameState = NameState(
+ prefix = "Dr",
+ first = "John",
+ middle = "M",
+ last = "Doe",
+ suffix = "Jr",
+ ),
+ phoneNumbers = listOf(PhoneFieldState(number = "555")),
+ emails = listOf(
+ EmailFieldState(address = "a@b.com"),
+ ),
+ addresses = listOf(
+ AddressFieldState(
+ street = "123 Main",
+ ),
+ ),
+ organization = OrganizationFieldState(
+ company = "Acme",
+ title = "Eng",
+ ),
+ events = listOf(
+ EventFieldState(
+ startDate = "1990-01-01",
+ ),
+ ),
+ relations = listOf(
+ RelationFieldState(name = "Jane"),
+ ),
+ imAccounts = listOf(
+ ImFieldState(data = "user@jabber"),
+ ),
+ websites = listOf(
+ WebsiteFieldState(
+ url = "https://site.com",
+ ),
+ ),
+ note = "Important",
+ nickname = "Johnny",
+ sipAddress = "sip:user@voip.example.com",
+ photoUri = Uri.parse("content://media/external/images/99"),
+ showOrganization = true,
+ )
+ val vm = createViewModel(initialState = savedState)
+ val restored = vm.uiState.value
+
+ assertEquals("Dr", restored.nameState.prefix)
+ assertEquals("John", restored.nameState.first)
+ assertEquals("M", restored.nameState.middle)
+ assertEquals("Doe", restored.nameState.last)
+ assertEquals("Jr", restored.nameState.suffix)
+ assertEquals("555", restored.phoneNumbers[0].number)
+ assertEquals("a@b.com", restored.emails[0].address)
+ assertEquals("123 Main", restored.addresses[0].street)
+ assertEquals("Acme", restored.organization.company)
+ assertEquals("Eng", restored.organization.title)
+ assertEquals("1990-01-01", restored.events[0].startDate)
+ assertEquals("Jane", restored.relations[0].name)
+ assertEquals("user@jabber", restored.imAccounts[0].data)
+ assertEquals("https://site.com", restored.websites[0].url)
+ assertEquals("Important", restored.note)
+ assertEquals("Johnny", restored.nickname)
+ assertEquals("sip:user@voip.example.com", restored.sipAddress)
+ assertEquals(Uri.parse("content://media/external/images/99"), restored.photoUri)
+ assertTrue(restored.showOrganization)
+ }
+
+ // --- Section visibility actions ---
+
+ @Test
+ fun showOrganization_setsShowOrganizationTrue() {
+ val vm = createViewModel()
+ assertFalse(vm.uiState.value.showOrganization)
+ vm.onAction(ContactCreationAction.ShowOrganization)
+ assertTrue(vm.uiState.value.showOrganization)
+ }
+
+ @Test
+ fun hideOrganization_clearsOrgAndHides() {
+ val vm = createViewModel(
+ initialState = ContactCreationUiState(
+ showOrganization = true,
+ organization = OrganizationFieldState(company = "Acme"),
+ ),
+ )
+ vm.onAction(ContactCreationAction.HideOrganization)
+ assertFalse(vm.uiState.value.showOrganization)
+ assertEquals("", vm.uiState.value.organization.company)
+ }
+
+ @Test
+ fun showNote_setsShowNoteTrue() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.ShowNote)
+ assertTrue(vm.uiState.value.showNote)
+ }
+
+ @Test
+ fun hideNote_clearsNoteAndHides() {
+ val vm = createViewModel(
+ initialState = ContactCreationUiState(showNote = true, note = "hello"),
+ )
+ vm.onAction(ContactCreationAction.HideNote)
+ assertFalse(vm.uiState.value.showNote)
+ assertEquals("", vm.uiState.value.note)
+ }
+
+ @Test
+ fun showNickname_setsShowNicknameTrue() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.ShowNickname)
+ assertTrue(vm.uiState.value.showNickname)
+ }
+
+ @Test
+ fun hideNickname_clearsAndHides() {
+ val vm = createViewModel(
+ initialState = ContactCreationUiState(showNickname = true, nickname = "JD"),
+ )
+ vm.onAction(ContactCreationAction.HideNickname)
+ assertFalse(vm.uiState.value.showNickname)
+ assertEquals("", vm.uiState.value.nickname)
+ }
+
+ @Test
+ fun showSipAddress_setsShowSipAddressTrue() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.ShowSipAddress)
+ assertTrue(vm.uiState.value.showSipAddress)
+ }
+
+ @Test
+ fun hideSipAddress_clearsAndHides() {
+ val vm = createViewModel(
+ initialState = ContactCreationUiState(showSipAddress = true, sipAddress = "sip:x"),
+ )
+ vm.onAction(ContactCreationAction.HideSipAddress)
+ assertFalse(vm.uiState.value.showSipAddress)
+ assertEquals("", vm.uiState.value.sipAddress)
+ }
+
+ // --- Extended field actions ---
+
+ @Test
+ fun addAddress_addsRow() {
+ val vm = createViewModel()
+ assertTrue(vm.uiState.value.addresses.isEmpty())
+ vm.onAction(ContactCreationAction.AddAddress)
+ assertEquals(1, vm.uiState.value.addresses.size)
+ }
+
+ @Test
+ fun addEvent_addsRow() {
+ val vm = createViewModel()
+ assertTrue(vm.uiState.value.events.isEmpty())
+ vm.onAction(ContactCreationAction.AddEvent)
+ assertEquals(1, vm.uiState.value.events.size)
+ }
+
+ @Test
+ fun updateNote_updatesState() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateNote("A note"))
+ assertEquals("A note", vm.uiState.value.note)
+ }
+
+ @Test
+ fun updateNickname_updatesState() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateNickname("Johnny"))
+ assertEquals("Johnny", vm.uiState.value.nickname)
+ }
+
+ @Test
+ fun updateSipAddress_updatesState() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateSipAddress("sip:user@voip"))
+ assertEquals("sip:user@voip", vm.uiState.value.sipAddress)
+ }
+
+ @Test
+ fun updateCompany_updatesState() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateCompany("Acme"))
+ assertEquals("Acme", vm.uiState.value.organization.company)
+ }
+
+ @Test
+ fun updateJobTitle_updatesState() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateJobTitle("Engineer"))
+ assertEquals("Engineer", vm.uiState.value.organization.title)
+ }
+
+ @Test
+ fun selectAccount_clearsGroups() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.ToggleGroup(1L, "Friends"))
+ assertEquals(1, vm.uiState.value.groups.size)
+
+ val account = AccountWithDataSet(
+ "test",
+ "com.test",
+ null,
+ )
+ vm.onAction(ContactCreationAction.SelectAccount(account))
+ assertTrue(vm.uiState.value.groups.isEmpty())
+ assertEquals(account, vm.uiState.value.selectedAccount)
+ }
+
+ @Test
+ fun hasPendingChanges_trueForNote() {
+ val vm = createViewModel()
+ vm.onAction(ContactCreationAction.UpdateNote("text"))
+ assertTrue(vm.uiState.value.hasPendingChanges())
+ }
+
+ @Test
+ fun hasPendingChanges_falseForDefaultState() {
+ val vm = createViewModel()
+ assertFalse(vm.uiState.value.hasPendingChanges())
+ }
+
+ private fun createViewModel(
+ initialState: ContactCreationUiState = ContactCreationUiState(),
+ ): ContactCreationViewModel {
+ val savedStateHandle = SavedStateHandle(
+ mapOf(ContactCreationViewModel.STATE_KEY to initialState),
+ )
+ return ContactCreationViewModel(
+ savedStateHandle = savedStateHandle,
+ deltaMapper = RawContactDeltaMapper(),
+ accountTypeManager = com.android.contacts.model.AccountTypeManager.getInstance(
+ RuntimeEnvironment.getApplication(),
+ ),
+ defaultDispatcher = mainDispatcherRule.testDispatcher,
+ appContext = RuntimeEnvironment.getApplication(),
+ )
+ }
+}
diff --git a/app/src/test/java/com/android/contacts/ui/contactcreation/TestFactory.kt b/app/src/test/java/com/android/contacts/ui/contactcreation/TestFactory.kt
new file mode 100644
index 000000000..f6bf761a1
--- /dev/null
+++ b/app/src/test/java/com/android/contacts/ui/contactcreation/TestFactory.kt
@@ -0,0 +1,68 @@
+package com.android.contacts.ui.contactcreation
+
+import android.net.Uri
+import com.android.contacts.ui.contactcreation.component.AddressType
+import com.android.contacts.ui.contactcreation.component.EmailType
+import com.android.contacts.ui.contactcreation.component.EventType
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.component.RelationType
+import com.android.contacts.ui.contactcreation.component.WebsiteType
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.contactcreation.model.EventFieldState
+import com.android.contacts.ui.contactcreation.model.GroupFieldState
+import com.android.contacts.ui.contactcreation.model.ImFieldState
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.contactcreation.model.RelationFieldState
+import com.android.contacts.ui.contactcreation.model.WebsiteFieldState
+
+internal object TestFactory {
+
+ fun fullState() = ContactCreationUiState(
+ nameState = NameState(first = "Jane", last = "Doe"),
+ phoneNumbers = listOf(
+ PhoneFieldState(id = "phone-1", number = "555-1234", type = PhoneType.Mobile)
+ ),
+ emails = listOf(
+ EmailFieldState(id = "email-1", address = "test@example.com", type = EmailType.Home)
+ ),
+ addresses = listOf(
+ AddressFieldState(
+ id = "addr-1",
+ street = "123 Main St",
+ city = "Springfield",
+ type = AddressType.Home,
+ ),
+ ),
+ organization = OrganizationFieldState(company = "Acme Corp", title = "Engineer"),
+ events = listOf(
+ EventFieldState(id = "event-1", startDate = "1990-01-15", type = EventType.Birthday)
+ ),
+ relations = listOf(
+ RelationFieldState(id = "rel-1", name = "Jane Doe", type = RelationType.Spouse)
+ ),
+ imAccounts = listOf(
+ ImFieldState(id = "im-1", data = "user@jabber", protocol = ImProtocol.Jabber)
+ ),
+ websites = listOf(
+ WebsiteFieldState(
+ id = "web-1",
+ url = "https://example.com",
+ type = WebsiteType.Homepage
+ )
+ ),
+ note = "Important note",
+ nickname = "JD",
+ sipAddress = "sip:jane@voip.example.com",
+ groups = listOf(GroupFieldState(groupId = 1L, title = "Friends")),
+ photoUri = Uri.parse("content://media/external/images/99"),
+ showOrganization = true,
+ showNote = true,
+ showNickname = true,
+ showSipAddress = true,
+ )
+}
diff --git a/app/src/test/java/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapperTest.kt b/app/src/test/java/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapperTest.kt
new file mode 100644
index 000000000..bc4351017
--- /dev/null
+++ b/app/src/test/java/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapperTest.kt
@@ -0,0 +1,990 @@
+package com.android.contacts.ui.contactcreation.mapper
+
+import android.net.Uri
+import android.provider.ContactsContract.CommonDataKinds.Email
+import android.provider.ContactsContract.CommonDataKinds.Event
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership
+import android.provider.ContactsContract.CommonDataKinds.Im
+import android.provider.ContactsContract.CommonDataKinds.Nickname
+import android.provider.ContactsContract.CommonDataKinds.Note
+import android.provider.ContactsContract.CommonDataKinds.Organization
+import android.provider.ContactsContract.CommonDataKinds.Phone
+import android.provider.ContactsContract.CommonDataKinds.Relation
+import android.provider.ContactsContract.CommonDataKinds.SipAddress
+import android.provider.ContactsContract.CommonDataKinds.StructuredName
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal
+import android.provider.ContactsContract.CommonDataKinds.Website
+import com.android.contacts.ui.contactcreation.component.AddressType
+import com.android.contacts.ui.contactcreation.component.EmailType
+import com.android.contacts.ui.contactcreation.component.EventType
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.component.RelationType
+import com.android.contacts.ui.contactcreation.component.WebsiteType
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.contactcreation.model.EventFieldState
+import com.android.contacts.ui.contactcreation.model.GroupFieldState
+import com.android.contacts.ui.contactcreation.model.ImFieldState
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.contactcreation.model.RelationFieldState
+import com.android.contacts.ui.contactcreation.model.WebsiteFieldState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@Suppress("LargeClass")
+@RunWith(RobolectricTestRunner::class)
+class RawContactDeltaMapperTest {
+
+ private val mapper = RawContactDeltaMapper()
+
+ // --- Name ---
+
+ @Test
+ fun mapsName_toStructuredNameDelta() {
+ val state = ContactCreationUiState(
+ nameState = NameState(first = "John", last = "Doe"),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("John", entries[0].getAsString(StructuredName.GIVEN_NAME))
+ assertEquals("Doe", entries[0].getAsString(StructuredName.FAMILY_NAME))
+ }
+
+ @Test
+ fun mapsFullName_withAllFields() {
+ val state = ContactCreationUiState(
+ nameState = NameState(
+ prefix = "Dr",
+ first = "John",
+ middle = "M",
+ last = "Doe",
+ suffix = "Jr",
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals("Dr", entry.getAsString(StructuredName.PREFIX))
+ assertEquals("John", entry.getAsString(StructuredName.GIVEN_NAME))
+ assertEquals("M", entry.getAsString(StructuredName.MIDDLE_NAME))
+ assertEquals("Doe", entry.getAsString(StructuredName.FAMILY_NAME))
+ assertEquals("Jr", entry.getAsString(StructuredName.SUFFIX))
+ }
+
+ @Test
+ fun emptyName_notIncluded() {
+ val state = ContactCreationUiState(
+ nameState = NameState(),
+ phoneNumbers = listOf(PhoneFieldState(number = "555")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ // --- Phone ---
+
+ @Test
+ fun mapsPhone_toPhoneDelta() {
+ val state = ContactCreationUiState(
+ phoneNumbers = listOf(PhoneFieldState(number = "555-1234", type = PhoneType.Mobile)),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("555-1234", entries[0].getAsString(Phone.NUMBER))
+ assertEquals(Phone.TYPE_MOBILE, entries[0].getAsInteger(Phone.TYPE))
+ }
+
+ @Test
+ fun emptyPhone_notIncluded() {
+ val state = ContactCreationUiState(
+ phoneNumbers = listOf(PhoneFieldState(number = "")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun multiplePhones_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ phoneNumbers = listOf(
+ PhoneFieldState(number = "111"),
+ PhoneFieldState(number = "222"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ @Test
+ fun customPhoneType_setsBothTypeAndLabel() {
+ val state = ContactCreationUiState(
+ phoneNumbers = listOf(
+ PhoneFieldState(number = "555", type = PhoneType.Custom("Satellite")),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Phone.TYPE_CUSTOM, entry.getAsInteger(Phone.TYPE))
+ assertEquals("Satellite", entry.getAsString(Phone.LABEL))
+ }
+
+ // --- Email ---
+
+ @Test
+ fun mapsEmail_toEmailDelta() {
+ val state = ContactCreationUiState(
+ emails = listOf(EmailFieldState(address = "john@example.com", type = EmailType.Home)),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("john@example.com", entries[0].getAsString(Email.DATA))
+ assertEquals(Email.TYPE_HOME, entries[0].getAsInteger(Email.TYPE))
+ }
+
+ @Test
+ fun emptyEmail_notIncluded() {
+ val state = ContactCreationUiState(
+ emails = listOf(EmailFieldState(address = "")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun customEmailType_setsBothTypeAndLabel() {
+ val state = ContactCreationUiState(
+ emails = listOf(
+ EmailFieldState(address = "a@b.com", type = EmailType.Custom("VIP")),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Email.TYPE_CUSTOM, entry.getAsInteger(Email.TYPE))
+ assertEquals("VIP", entry.getAsString(Email.LABEL))
+ }
+
+ // --- Address ---
+
+ @Test
+ fun mapsAddress_toStructuredPostalDelta() {
+ val state = ContactCreationUiState(
+ addresses = listOf(
+ AddressFieldState(
+ street = "123 Main St",
+ city = "Springfield",
+ region = "IL",
+ postcode = "62701",
+ country = "US",
+ type = AddressType.Home,
+ ),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("123 Main St", entries[0].getAsString(StructuredPostal.STREET))
+ assertEquals("Springfield", entries[0].getAsString(StructuredPostal.CITY))
+ assertEquals("IL", entries[0].getAsString(StructuredPostal.REGION))
+ assertEquals("62701", entries[0].getAsString(StructuredPostal.POSTCODE))
+ assertEquals("US", entries[0].getAsString(StructuredPostal.COUNTRY))
+ assertEquals(StructuredPostal.TYPE_HOME, entries[0].getAsInteger(StructuredPostal.TYPE))
+ }
+
+ @Test
+ fun emptyAddress_notIncluded() {
+ val state = ContactCreationUiState(
+ addresses = listOf(AddressFieldState()),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun addressWithOnlyCityFilled_isIncluded() {
+ val state = ContactCreationUiState(
+ addresses = listOf(AddressFieldState(city = "Chicago")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("Chicago", entries[0].getAsString(StructuredPostal.CITY))
+ }
+
+ @Test
+ fun customAddressType_setsBothTypeAndLabel() {
+ val state = ContactCreationUiState(
+ addresses = listOf(
+ AddressFieldState(
+ street = "1 Elm",
+ type = AddressType.Custom("Vacation"),
+ ),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(StructuredPostal.TYPE_CUSTOM, entry.getAsInteger(StructuredPostal.TYPE))
+ assertEquals("Vacation", entry.getAsString(StructuredPostal.LABEL))
+ }
+
+ // --- Organization ---
+
+ @Test
+ fun mapsOrganization_toOrgDelta() {
+ val state = ContactCreationUiState(
+ organization = OrganizationFieldState(company = "Acme", title = "Engineer"),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("Acme", entries[0].getAsString(Organization.COMPANY))
+ assertEquals("Engineer", entries[0].getAsString(Organization.TITLE))
+ }
+
+ @Test
+ fun emptyOrganization_notIncluded() {
+ val state = ContactCreationUiState(
+ organization = OrganizationFieldState(),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun orgWithOnlyCompany_isIncluded() {
+ val state = ContactCreationUiState(
+ organization = OrganizationFieldState(company = "Acme"),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("Acme", entries[0].getAsString(Organization.COMPANY))
+ }
+
+ // --- Note ---
+
+ @Test
+ fun mapsNote_toNoteDelta() {
+ val state = ContactCreationUiState(note = "Important person")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Note.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("Important person", entries[0].getAsString(Note.NOTE))
+ }
+
+ @Test
+ fun emptyNote_notIncluded() {
+ val state = ContactCreationUiState(note = "")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Note.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ // --- Website ---
+
+ @Test
+ fun mapsWebsite_toWebsiteDelta() {
+ val state = ContactCreationUiState(
+ websites = listOf(
+ WebsiteFieldState(url = "https://example.com", type = WebsiteType.Homepage),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("https://example.com", entries[0].getAsString(Website.URL))
+ assertEquals(Website.TYPE_HOMEPAGE, entries[0].getAsInteger(Website.TYPE))
+ }
+
+ @Test
+ fun emptyWebsite_notIncluded() {
+ val state = ContactCreationUiState(
+ websites = listOf(WebsiteFieldState(url = "")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun customWebsiteType_setsBothTypeAndLabel() {
+ val state = ContactCreationUiState(
+ websites = listOf(
+ WebsiteFieldState(
+ url = "https://blog.example.com",
+ type = WebsiteType.Custom("Portfolio"),
+ ),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Website.TYPE_CUSTOM, entry.getAsInteger(Website.TYPE))
+ assertEquals("Portfolio", entry.getAsString(Website.LABEL))
+ }
+
+ // --- Event ---
+
+ @Test
+ fun mapsEvent_toEventDelta() {
+ val state = ContactCreationUiState(
+ events = listOf(
+ EventFieldState(startDate = "1990-01-15", type = EventType.Birthday),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("1990-01-15", entries[0].getAsString(Event.START_DATE))
+ assertEquals(Event.TYPE_BIRTHDAY, entries[0].getAsInteger(Event.TYPE))
+ }
+
+ @Test
+ fun emptyEvent_notIncluded() {
+ val state = ContactCreationUiState(
+ events = listOf(EventFieldState(startDate = "")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun customEventType_setsBothTypeAndLabel() {
+ val state = ContactCreationUiState(
+ events = listOf(
+ EventFieldState(
+ startDate = "2020-06-01",
+ type = EventType.Custom("First met"),
+ ),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Event.TYPE_CUSTOM, entry.getAsInteger(Event.TYPE))
+ assertEquals("First met", entry.getAsString(Event.LABEL))
+ }
+
+ // --- Relation ---
+
+ @Test
+ fun mapsRelation_toRelationDelta() {
+ val state = ContactCreationUiState(
+ relations = listOf(
+ RelationFieldState(name = "Jane Doe", type = RelationType.Spouse),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("Jane Doe", entries[0].getAsString(Relation.NAME))
+ assertEquals(Relation.TYPE_SPOUSE, entries[0].getAsInteger(Relation.TYPE))
+ }
+
+ @Test
+ fun emptyRelation_notIncluded() {
+ val state = ContactCreationUiState(
+ relations = listOf(RelationFieldState(name = "")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun customRelationType_setsBothTypeAndLabel() {
+ val state = ContactCreationUiState(
+ relations = listOf(
+ RelationFieldState(
+ name = "Bob",
+ type = RelationType.Custom("Mentor"),
+ ),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Relation.TYPE_CUSTOM, entry.getAsInteger(Relation.TYPE))
+ assertEquals("Mentor", entry.getAsString(Relation.LABEL))
+ }
+
+ // --- IM (PROTOCOL + CUSTOM_PROTOCOL, not TYPE + LABEL) ---
+
+ @Test
+ fun mapsIm_toImDelta_withProtocol() {
+ val state = ContactCreationUiState(
+ imAccounts = listOf(
+ ImFieldState(data = "user@jabber.org", protocol = ImProtocol.Jabber),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("user@jabber.org", entries[0].getAsString(Im.DATA))
+ assertEquals(Im.PROTOCOL_JABBER, entries[0].getAsInteger(Im.PROTOCOL))
+ }
+
+ @Test
+ fun emptyIm_notIncluded() {
+ val state = ContactCreationUiState(
+ imAccounts = listOf(ImFieldState(data = "")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun customImProtocol_setsProtocolAndCustomProtocol() {
+ val state = ContactCreationUiState(
+ imAccounts = listOf(
+ ImFieldState(
+ data = "user123",
+ protocol = ImProtocol.Custom("Matrix"),
+ ),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Im.PROTOCOL_CUSTOM, entry.getAsInteger(Im.PROTOCOL))
+ assertEquals("Matrix", entry.getAsString(Im.CUSTOM_PROTOCOL))
+ }
+
+ // --- Nickname ---
+
+ @Test
+ fun mapsNickname_toNicknameDelta() {
+ val state = ContactCreationUiState(nickname = "Johnny")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Nickname.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals("Johnny", entries[0].getAsString(Nickname.NAME))
+ }
+
+ @Test
+ fun emptyNickname_notIncluded() {
+ val state = ContactCreationUiState(nickname = "")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Nickname.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ // --- SIP ---
+
+ @Test
+ fun mapsSipAddress_toSipDelta() {
+ val state = ContactCreationUiState(sipAddress = "sip:user@voip.example.com")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(SipAddress.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals(
+ "sip:user@voip.example.com",
+ entries[0].getAsString(SipAddress.SIP_ADDRESS),
+ )
+ }
+
+ @Test
+ fun emptySipAddress_notIncluded() {
+ val state = ContactCreationUiState(sipAddress = "")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(SipAddress.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ // --- Group Membership ---
+
+ @Test
+ fun mapsGroup_toGroupMembershipDelta() {
+ val state = ContactCreationUiState(
+ groups = listOf(GroupFieldState(groupId = 42L, title = "Friends")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(GroupMembership.CONTENT_ITEM_TYPE)
+
+ assertNotNull(entries)
+ assertEquals(1, entries!!.size)
+ assertEquals(42L, entries[0].getAsLong(GroupMembership.GROUP_ROW_ID))
+ }
+
+ @Test
+ fun multipleGroups_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ groups = listOf(
+ GroupFieldState(groupId = 1L, title = "Friends"),
+ GroupFieldState(groupId = 2L, title = "Family"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(GroupMembership.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ // --- Account ---
+
+ @Test
+ fun nullAccount_setsLocalAccount() {
+ val state = ContactCreationUiState(
+ nameState = NameState(first = "Test"),
+ )
+ val result = mapper.map(state, account = null)
+
+ assertNull(result.state[0].values.getAsString("account_name"))
+ }
+
+ // --- Mixed fields ---
+
+ @Test
+ fun mixedEmptyAndFilledFields_onlyMapsFilledOnes() {
+ val state = ContactCreationUiState(
+ phoneNumbers = listOf(
+ PhoneFieldState(number = ""),
+ PhoneFieldState(number = "555"),
+ PhoneFieldState(number = " "),
+ ),
+ emails = listOf(
+ EmailFieldState(address = ""),
+ EmailFieldState(address = "a@b.com"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+
+ assertEquals(1, result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)!!.size)
+ assertEquals(1, result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)!!.size)
+ }
+
+ // --- Photo ---
+
+ @Test
+ fun photoUri_inUpdatedPhotosBundle() {
+ val photoUri = Uri.parse("content://media/external/images/1234")
+ val state = ContactCreationUiState(
+ nameState = NameState(first = "Photo"),
+ photoUri = photoUri,
+ )
+ val result = mapper.map(state, account = null)
+ val tempId = result.state[0].values.id.toString()
+ val bundledUri = result.updatedPhotos.getParcelable(tempId)
+
+ assertEquals(photoUri, bundledUri)
+ }
+
+ @Test
+ fun nullPhotoUri_emptyUpdatedPhotosBundle() {
+ val state = ContactCreationUiState(
+ nameState = NameState(first = "NoPhoto"),
+ photoUri = null,
+ )
+ val result = mapper.map(state, account = null)
+
+ assertTrue(result.updatedPhotos.isEmpty)
+ }
+
+ @Test
+ fun multipleAddresses_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ addresses = listOf(
+ AddressFieldState(street = "1 First St"),
+ AddressFieldState(city = "Second City"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ // --- Multiple entries for repeatable fields ---
+
+ @Test
+ fun multipleEmails_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ emails = listOf(
+ EmailFieldState(address = "a@b.com"),
+ EmailFieldState(address = "c@d.com"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ @Test
+ fun multipleEvents_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ events = listOf(
+ EventFieldState(startDate = "2020-01-01"),
+ EventFieldState(startDate = "2021-06-15"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ @Test
+ fun multipleRelations_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ relations = listOf(
+ RelationFieldState(name = "Jane"),
+ RelationFieldState(name = "Bob"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ @Test
+ fun multipleImAccounts_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ imAccounts = listOf(
+ ImFieldState(data = "user1@jabber.org"),
+ ImFieldState(data = "user2@jabber.org"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ @Test
+ fun multipleWebsites_producesMultipleEntries() {
+ val state = ContactCreationUiState(
+ websites = listOf(
+ WebsiteFieldState(url = "https://one.com"),
+ WebsiteFieldState(url = "https://two.com"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE)
+
+ assertEquals(2, entries!!.size)
+ }
+
+ // --- Non-custom types do NOT set LABEL column ---
+
+ @Test
+ fun nonCustomPhoneType_doesNotSetLabel() {
+ val state = ContactCreationUiState(
+ phoneNumbers = listOf(PhoneFieldState(number = "555", type = PhoneType.Home)),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Phone.TYPE_HOME, entry.getAsInteger(Phone.TYPE))
+ assertNull(entry.getAsString(Phone.LABEL))
+ }
+
+ @Test
+ fun nonCustomEmailType_doesNotSetLabel() {
+ val state = ContactCreationUiState(
+ emails = listOf(EmailFieldState(address = "a@b.com", type = EmailType.Work)),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Email.TYPE_WORK, entry.getAsInteger(Email.TYPE))
+ assertNull(entry.getAsString(Email.LABEL))
+ }
+
+ @Test
+ fun nonCustomImProtocol_doesNotSetCustomProtocol() {
+ val state = ContactCreationUiState(
+ imAccounts = listOf(ImFieldState(data = "user", protocol = ImProtocol.Skype)),
+ )
+ val result = mapper.map(state, account = null)
+ val entry = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)!![0]
+
+ assertEquals(Im.PROTOCOL_SKYPE, entry.getAsInteger(Im.PROTOCOL))
+ assertNull(entry.getAsString(Im.CUSTOM_PROTOCOL))
+ }
+
+ // --- Temp ID is negative ---
+
+ @Test
+ fun tempId_isNegative() {
+ val state = ContactCreationUiState(
+ nameState = NameState(first = "Test"),
+ )
+ val result = mapper.map(state, account = null)
+ val tempId = result.state[0].values.id
+
+ assertTrue("Temp ID should be negative, was $tempId", tempId < 0)
+ }
+
+ // --- Whitespace-only fields treated as blank ---
+
+ @Test
+ fun whitespaceOnlyPhone_notIncluded() {
+ val state = ContactCreationUiState(
+ phoneNumbers = listOf(PhoneFieldState(number = " \t ")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyEmail_notIncluded() {
+ val state = ContactCreationUiState(
+ emails = listOf(EmailFieldState(address = " ")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyNote_notIncluded() {
+ val state = ContactCreationUiState(note = " \n ")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Note.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyNickname_notIncluded() {
+ val state = ContactCreationUiState(nickname = " ")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Nickname.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlySipAddress_notIncluded() {
+ val state = ContactCreationUiState(sipAddress = " \t ")
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(SipAddress.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyName_notIncluded() {
+ val state = ContactCreationUiState(
+ nameState = NameState(first = " ", last = " \t"),
+ phoneNumbers = listOf(PhoneFieldState(number = "555")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyWebsite_notIncluded() {
+ val state = ContactCreationUiState(
+ websites = listOf(WebsiteFieldState(url = " ")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyEvent_notIncluded() {
+ val state = ContactCreationUiState(
+ events = listOf(EventFieldState(startDate = " ")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyRelation_notIncluded() {
+ val state = ContactCreationUiState(
+ relations = listOf(RelationFieldState(name = " ")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyIm_notIncluded() {
+ val state = ContactCreationUiState(
+ imAccounts = listOf(ImFieldState(data = " ")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyAddress_notIncluded() {
+ val state = ContactCreationUiState(
+ addresses = listOf(AddressFieldState(street = " ", city = " \t")),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ @Test
+ fun whitespaceOnlyOrganization_notIncluded() {
+ val state = ContactCreationUiState(
+ organization = OrganizationFieldState(company = " ", title = " \t"),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE)
+
+ assertTrue(entries.isNullOrEmpty())
+ }
+
+ // --- Mixed blank/populated repeatable fields (only populated saved) ---
+
+ @Test
+ fun mixedBlankAndPopulatedEvents_onlyMapsPopulated() {
+ val state = ContactCreationUiState(
+ events = listOf(
+ EventFieldState(startDate = ""),
+ EventFieldState(startDate = "2020-01-01"),
+ EventFieldState(startDate = " "),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE)
+
+ assertEquals(1, entries!!.size)
+ assertEquals("2020-01-01", entries[0].getAsString(Event.START_DATE))
+ }
+
+ @Test
+ fun mixedBlankAndPopulatedRelations_onlyMapsPopulated() {
+ val state = ContactCreationUiState(
+ relations = listOf(
+ RelationFieldState(name = ""),
+ RelationFieldState(name = "Jane"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE)
+
+ assertEquals(1, entries!!.size)
+ assertEquals("Jane", entries[0].getAsString(Relation.NAME))
+ }
+
+ @Test
+ fun mixedBlankAndPopulatedWebsites_onlyMapsPopulated() {
+ val state = ContactCreationUiState(
+ websites = listOf(
+ WebsiteFieldState(url = ""),
+ WebsiteFieldState(url = "https://site.com"),
+ WebsiteFieldState(url = " "),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE)
+
+ assertEquals(1, entries!!.size)
+ assertEquals("https://site.com", entries[0].getAsString(Website.URL))
+ }
+
+ @Test
+ fun mixedBlankAndPopulatedIms_onlyMapsPopulated() {
+ val state = ContactCreationUiState(
+ imAccounts = listOf(
+ ImFieldState(data = ""),
+ ImFieldState(data = "user@jabber"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)
+
+ assertEquals(1, entries!!.size)
+ assertEquals("user@jabber", entries[0].getAsString(Im.DATA))
+ }
+
+ @Test
+ fun mixedBlankAndPopulatedAddresses_onlyMapsPopulated() {
+ val state = ContactCreationUiState(
+ addresses = listOf(
+ AddressFieldState(),
+ AddressFieldState(street = "123 Main"),
+ ),
+ )
+ val result = mapper.map(state, account = null)
+ val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)
+
+ assertEquals(1, entries!!.size)
+ assertEquals("123 Main", entries[0].getAsString(StructuredPostal.STREET))
+ }
+}
diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties
new file mode 100644
index 000000000..3f67ea5ac
--- /dev/null
+++ b/app/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=35
diff --git a/build.gradle.kts b/build.gradle.kts
index 1e673e6ff..11198da13 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.ktlint)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c9754de41..f10251bb0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,8 +8,12 @@ ktlint = "1.8.0"
ktlint-gradle = "14.2.0"
activity-compose = "1.13.0"
+coil = "3.2.0"
+collections-immutable = "0.4.0" # First stable release — 0.3.x were all pre-release
+hilt-navigation-compose = "1.3.0"
appcompat = "1.7.1"
compose-bom = "2026.03.01"
+compose-material3-expressive = "1.5.0-alpha17" # Pinned for M3 Expressive APIs (MotionScheme, shapes)
coroutines = "1.10.2"
guava = "33.5.0-android"
material = "1.13.0"
@@ -33,22 +37,27 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
-androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
+androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3-expressive" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
+
androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
+coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
+
guava = { module = "com.google.guava:guava", version.ref = "guava" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
+kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "collections-immutable" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
@@ -77,5 +86,6 @@ detekt = { id = "dev.detekt", version.ref = "detekt" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index d88942143..23524daaa 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -3,6042 +3,8069 @@
true
false
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
+
+
+
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 089023280..95a99c896 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -572,6 +572,63 @@
at a pre-determined text size. [CHAR LIMIT=20] -->
View only
+
+ First name
+ Last name
+ Company
+ Title
+ Note
+ SIP
+ Date
+ Choose photo
+ Contact photo
+ Add photo
+ Add phone
+ Add email
+ Add address
+ Add event
+ Add relation
+ Add IM
+ Add website
+ More fields
+ Less fields
+ Change type
+ Remove phone
+ Remove email
+ Remove address
+ Remove event
+ Remove relation
+ Remove IM
+ Remove website
+ Device
+ Groups
+ You have unsaved changes that will be lost.
+ Keep editing
+ Custom label
+ Label
+ Name
+ Phone
+ Email
+ Address
+ Organization
+ Groups
+ Add more info
+ Other
+ Save
+ Close
+
+
+ Mobile
+ Home
+ Work
+ Work mobile
+ Main
+ Work fax
+ Home fax
+ Pager
+ Other
+ Custom\u2026
+
Choose contact to edit
diff --git a/res/xml/file_paths.xml b/res/xml/file_paths.xml
index 294c0cbfc..4966fcead 100644
--- a/res/xml/file_paths.xml
+++ b/res/xml/file_paths.xml
@@ -15,6 +15,11 @@
-->
-
-
+
+
diff --git a/src/com/android/contacts/ui/contactcreation/ContactCreationActivity.kt b/src/com/android/contacts/ui/contactcreation/ContactCreationActivity.kt
new file mode 100644
index 000000000..42e1caf8b
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/ContactCreationActivity.kt
@@ -0,0 +1,181 @@
+package com.android.contacts.ui.contactcreation
+
+import android.content.Intent
+import android.os.Bundle
+import android.provider.ContactsContract.Intents.Insert
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import com.android.contacts.ContactSaveService
+import com.android.contacts.activities.ContactEditorActivity.ContactEditor.SaveMode
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.ContactCreationEffect
+import com.android.contacts.ui.core.AppTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+internal class ContactCreationActivity :
+ ComponentActivity(),
+ ContactSaveService.Listener {
+
+ private val viewModel: ContactCreationViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ if (savedInstanceState == null) {
+ val extras = sanitizeExtras(intent)
+ applyIntentExtras(extras)
+ }
+
+ setContent {
+ val galleryLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.PickVisualMedia(),
+ ) { uri ->
+ uri?.let { viewModel.onAction(ContactCreationAction.SetPhoto(it)) }
+ }
+
+ val cameraLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.TakePicture(),
+ ) { success ->
+ val uri = viewModel.pendingCameraUri
+ viewModel.pendingCameraUri = null
+ if (success && uri != null) {
+ viewModel.onAction(ContactCreationAction.SetPhoto(uri))
+ }
+ }
+
+ EffectCollector(galleryLauncher, cameraLauncher)
+
+ AppTheme {
+ val uiState by viewModel.uiState.collectAsState()
+ val accounts by viewModel.accounts.collectAsState()
+ ContactCreationEditorScreen(
+ uiState = uiState,
+ accounts = accounts,
+ onAction = viewModel::onAction,
+ )
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ ContactSaveService.registerListener(this)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ ContactSaveService.unregisterListener(this)
+ }
+
+ override fun onServiceCompleted(callbackIntent: Intent) {
+ val contactUri = callbackIntent.data
+ val isValidUri = contactUri == null ||
+ contactUri.authority == android.provider.ContactsContract.AUTHORITY
+ if (isValidUri) {
+ viewModel.onSaveResult(contactUri != null, contactUri)
+ }
+ }
+
+ @Composable
+ private fun EffectCollector(
+ galleryLauncher: ActivityResultLauncher,
+ cameraLauncher: ActivityResultLauncher,
+ ) {
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ handleEffect(effect, galleryLauncher, cameraLauncher)
+ }
+ }
+ }
+
+ private fun handleEffect(
+ effect: ContactCreationEffect,
+ galleryLauncher: ActivityResultLauncher,
+ cameraLauncher: ActivityResultLauncher,
+ ) {
+ when (effect) {
+ is ContactCreationEffect.Save -> {
+ val saveIntent = ContactSaveService.createSaveContactIntent(
+ this,
+ effect.result.state,
+ ContactCreationViewModel.SAVE_MODE_EXTRA_KEY,
+ SaveMode.CLOSE,
+ false,
+ ContactCreationActivity::class.java,
+ ContactCreationViewModel.SAVE_COMPLETED_ACTION,
+ effect.result.updatedPhotos,
+ null,
+ null,
+ )
+ startService(saveIntent)
+ }
+
+ is ContactCreationEffect.NavigateBack -> finish()
+ is ContactCreationEffect.SaveSuccess -> finish()
+
+ is ContactCreationEffect.ShowError -> {
+ Toast.makeText(this, getString(effect.messageResId), Toast.LENGTH_SHORT).show()
+ }
+
+ is ContactCreationEffect.LaunchGallery -> {
+ galleryLauncher.launch(
+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
+ )
+ }
+
+ is ContactCreationEffect.LaunchCamera -> cameraLauncher.launch(effect.outputUri)
+ }
+ }
+
+ private fun applyIntentExtras(extras: SanitizedExtras) {
+ extras.name?.let {
+ viewModel.onAction(ContactCreationAction.UpdateFirstName(it))
+ }
+ extras.phone?.let {
+ viewModel.onAction(
+ ContactCreationAction.UpdatePhone(
+ id = viewModel.uiState.value.phoneNumbers.first().id,
+ value = it,
+ ),
+ )
+ }
+ extras.email?.let {
+ viewModel.onAction(
+ ContactCreationAction.UpdateEmail(
+ id = viewModel.uiState.value.emails.first().id,
+ value = it,
+ ),
+ )
+ }
+ }
+
+ // Intentionally accepting only NAME, PHONE, EMAIL extras.
+ // Insert.PHONE_TYPE, Insert.SECONDARY_PHONE, Insert.COMPANY, Insert.NOTES,
+ // Insert.DATA (arbitrary ContentValues), and all other extras are ignored
+ // for minimum attack surface on GrapheneOS.
+ private fun sanitizeExtras(intent: Intent) = SanitizedExtras(
+ name = intent.getStringExtra(Insert.NAME)?.take(MAX_NAME_LEN),
+ phone = intent.getStringExtra(Insert.PHONE)?.take(MAX_PHONE_LEN),
+ email = intent.getStringExtra(Insert.EMAIL)?.take(MAX_EMAIL_LEN),
+ )
+
+ private data class SanitizedExtras(val name: String?, val phone: String?, val email: String?)
+}
+
+private const val MAX_NAME_LEN = 500
+private const val MAX_PHONE_LEN = 100
+private const val MAX_EMAIL_LEN = 320
diff --git a/src/com/android/contacts/ui/contactcreation/ContactCreationEditorScreen.kt b/src/com/android/contacts/ui/contactcreation/ContactCreationEditorScreen.kt
new file mode 100644
index 000000000..aff60b08a
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/ContactCreationEditorScreen.kt
@@ -0,0 +1,518 @@
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+
+package com.android.contacts.ui.contactcreation
+
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+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.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.ui.contactcreation.component.AddMoreInfoSection
+import com.android.contacts.ui.contactcreation.component.AddressSectionContent
+import com.android.contacts.ui.contactcreation.component.EmailSectionContent
+import com.android.contacts.ui.contactcreation.component.EventSectionContent
+import com.android.contacts.ui.contactcreation.component.FieldRow
+import com.android.contacts.ui.contactcreation.component.GroupSectionContent
+import com.android.contacts.ui.contactcreation.component.ImSectionContent
+import com.android.contacts.ui.contactcreation.component.NameSectionContent
+import com.android.contacts.ui.contactcreation.component.NicknameField
+import com.android.contacts.ui.contactcreation.component.OrganizationSectionContent
+import com.android.contacts.ui.contactcreation.component.OtherFieldsBottomSheet
+import com.android.contacts.ui.contactcreation.component.PhoneSectionContent
+import com.android.contacts.ui.contactcreation.component.PhotoSectionContent
+import com.android.contacts.ui.contactcreation.component.RelationSectionContent
+import com.android.contacts.ui.contactcreation.component.RemoveFieldButton
+import com.android.contacts.ui.contactcreation.component.SipField
+import com.android.contacts.ui.contactcreation.component.WebsiteSectionContent
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import kotlinx.coroutines.CancellationException
+
+@Composable
+internal fun ContactCreationEditorScreen(
+ uiState: ContactCreationUiState,
+ accounts: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ PredictiveBackHandler(enabled = true) { flow ->
+ try {
+ flow.collect { /* consume progress events */ }
+ onAction(ContactCreationAction.NavigateBack)
+ } catch (_: CancellationException) {
+ // Back gesture cancelled, do nothing
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(R.string.contact_editor_title_new_contact),
+ )
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = { onAction(ContactCreationAction.NavigateBack) },
+ shapes = IconButtonDefaults.shapes(),
+ modifier = Modifier.testTag(TestTags.CLOSE_BUTTON),
+ ) {
+ Icon(
+ Icons.Filled.Close,
+ contentDescription = stringResource(
+ R.string.contact_creation_close,
+ ),
+ )
+ }
+ },
+ actions = {
+ FilledTonalButton(
+ onClick = { onAction(ContactCreationAction.Save) },
+ shapes = ButtonDefaults.shapes(),
+ modifier = Modifier.testTag(TestTags.SAVE_TEXT_BUTTON),
+ enabled = !uiState.isSaving,
+ ) {
+ Text(stringResource(R.string.contact_creation_save))
+ }
+ },
+ )
+ },
+ ) { contentPadding ->
+ ContactCreationFieldsColumn(
+ uiState = uiState,
+ accounts = accounts,
+ onAction = onAction,
+ modifier = Modifier.padding(contentPadding),
+ )
+ }
+
+ if (uiState.showDiscardDialog) {
+ DiscardChangesDialog(onAction = onAction)
+ }
+}
+
+@Composable
+private fun DiscardChangesDialog(onAction: (ContactCreationAction) -> Unit) {
+ AlertDialog(
+ onDismissRequest = { onAction(ContactCreationAction.DismissDiscardDialog) },
+ title = { Text(stringResource(R.string.cancel_confirmation_dialog_message)) },
+ text = {
+ Text(
+ stringResource(R.string.contact_creation_discard_body),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onAction(ContactCreationAction.ConfirmDiscard) },
+ modifier = Modifier.testTag(TestTags.DISCARD_DIALOG_CONFIRM),
+ ) {
+ Text(stringResource(R.string.cancel_confirmation_dialog_cancel_editing_button))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { onAction(ContactCreationAction.DismissDiscardDialog) },
+ modifier = Modifier.testTag(TestTags.DISCARD_DIALOG_DISMISS),
+ ) {
+ Text(stringResource(R.string.contact_creation_keep_editing))
+ }
+ },
+ modifier = Modifier.testTag(TestTags.DISCARD_DIALOG),
+ )
+}
+
+@Composable
+private fun ContactCreationFieldsColumn(
+ uiState: ContactCreationUiState,
+ accounts: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var showAccountSheet by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ .imePadding(),
+ ) {
+ PhotoAndAccountHeader(uiState = uiState, onAction = onAction)
+ FieldSections(uiState = uiState, onAction = onAction)
+ AccountFooterBar(
+ accountName = uiState.accountName,
+ showPicker = accounts.size > 1,
+ onTap = { showAccountSheet = true },
+ )
+ Spacer(modifier = Modifier.height(48.dp))
+ }
+
+ if (showAccountSheet) {
+ AccountBottomSheet(
+ accounts = accounts,
+ selectedAccount = uiState.selectedAccount,
+ onAction = onAction,
+ onDismiss = { showAccountSheet = false },
+ )
+ }
+}
+
+@Composable
+private fun PhotoAndAccountHeader(
+ uiState: ContactCreationUiState,
+ onAction: (ContactCreationAction) -> Unit,
+) {
+ PhotoSectionContent(photoUri = uiState.photoUri, onAction = onAction)
+ Spacer(modifier = Modifier.height(16.dp))
+}
+
+@Suppress("LongMethod", "CyclomaticComplexMethod")
+@Composable
+private fun FieldSections(
+ uiState: ContactCreationUiState,
+ onAction: (ContactCreationAction) -> Unit,
+) {
+ var showOtherSheet by remember { mutableStateOf(false) }
+
+ // --- Always-visible sections ---
+
+ NameSectionContent(nameState = uiState.nameState, onAction = onAction)
+ Spacer(modifier = Modifier.height(8.dp))
+
+ PhoneSectionContent(phones = uiState.phoneNumbers, onAction = onAction)
+ Spacer(modifier = Modifier.height(16.dp))
+
+ EmailSectionContent(emails = uiState.emails, onAction = onAction)
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // --- Conditionally-visible sections ---
+
+ // Address
+ AnimatedVisibility(
+ visible = uiState.addresses.isNotEmpty(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ AddressSectionContent(addresses = uiState.addresses, onAction = onAction)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ // Organization
+ AnimatedVisibility(
+ visible = uiState.showOrganization || uiState.organization.hasData(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ OrganizationSectionContent(
+ organization = uiState.organization,
+ onAction = onAction,
+ )
+ RemoveFieldButton(
+ onClick = { onAction(ContactCreationAction.HideOrganization) },
+ contentDescription = "Remove organization",
+ modifier = Modifier.testTag(TestTags.ORG_REMOVE),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ // Nickname
+ AnimatedVisibility(
+ visible = uiState.showNickname || uiState.nickname.isNotBlank(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ NicknameField(nickname = uiState.nickname, onAction = onAction)
+ RemoveFieldButton(
+ onClick = { onAction(ContactCreationAction.HideNickname) },
+ contentDescription = "Remove nickname",
+ modifier = Modifier.testTag(TestTags.NICKNAME_REMOVE),
+ )
+ }
+ }
+
+ // SIP
+ AnimatedVisibility(
+ visible = uiState.showSipField &&
+ (uiState.showSipAddress || uiState.sipAddress.isNotBlank()),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ SipField(sipAddress = uiState.sipAddress, onAction = onAction)
+ RemoveFieldButton(
+ onClick = { onAction(ContactCreationAction.HideSipAddress) },
+ contentDescription = "Remove SIP address",
+ modifier = Modifier.testTag(TestTags.SIP_REMOVE),
+ )
+ }
+ }
+
+ // IM
+ AnimatedVisibility(
+ visible = uiState.imAccounts.isNotEmpty(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ Spacer(modifier = Modifier.height(16.dp))
+ ImSectionContent(imAccounts = uiState.imAccounts, onAction = onAction)
+ }
+ }
+
+ // Website
+ AnimatedVisibility(
+ visible = uiState.websites.isNotEmpty(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ Spacer(modifier = Modifier.height(16.dp))
+ WebsiteSectionContent(websites = uiState.websites, onAction = onAction)
+ }
+ }
+
+ // Events
+ AnimatedVisibility(
+ visible = uiState.events.isNotEmpty(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ Spacer(modifier = Modifier.height(16.dp))
+ EventSectionContent(events = uiState.events, onAction = onAction)
+ }
+ }
+
+ // Relations
+ AnimatedVisibility(
+ visible = uiState.relations.isNotEmpty(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ Spacer(modifier = Modifier.height(16.dp))
+ RelationSectionContent(relations = uiState.relations, onAction = onAction)
+ }
+ }
+
+ // Note
+ AnimatedVisibility(
+ visible = uiState.showNote || uiState.note.isNotBlank(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ FieldRow {
+ OutlinedTextField(
+ value = uiState.note,
+ onValueChange = { onAction(ContactCreationAction.UpdateNote(it)) },
+ label = { Text(stringResource(R.string.contact_creation_note)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.NOTE_FIELD),
+ singleLine = false,
+ maxLines = 4,
+ )
+ }
+ RemoveFieldButton(
+ onClick = { onAction(ContactCreationAction.HideNote) },
+ contentDescription = "Remove note",
+ modifier = Modifier.testTag(TestTags.NOTE_REMOVE),
+ )
+ }
+ }
+
+ // --- Chip grid (no outer AnimatedVisibility — inner per-chip animations + animateContentSize handle it) ---
+ AddMoreInfoSection(
+ showAddressChip = uiState.showAddressChip,
+ showOrgChip = uiState.showOrgChip,
+ showNoteChip = uiState.showNoteChip,
+ showGroupsChip = uiState.showGroupsChip,
+ showOtherChip = uiState.showOtherChip,
+ onAddAddress = { onAction(ContactCreationAction.AddAddress) },
+ onShowOrganization = { onAction(ContactCreationAction.ShowOrganization) },
+ onShowNote = { onAction(ContactCreationAction.ShowNote) },
+ onShowGroups = {
+ // Add first group toggle to show section; actual selection in GroupSectionContent
+ // For now just scroll to groups. We show groups section when groups is non-empty
+ // or user taps this chip — handled via availableGroups presence check below.
+ },
+ onShowOtherSheet = { showOtherSheet = true },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Groups
+ if (uiState.availableGroups.isNotEmpty()) {
+ GroupSectionContent(
+ availableGroups = uiState.availableGroups,
+ selectedGroups = uiState.groups,
+ onAction = onAction,
+ )
+ }
+
+ // Bottom sheet
+ if (showOtherSheet) {
+ OtherFieldsBottomSheet(
+ showEvents = uiState.events.isEmpty(),
+ showRelations = uiState.relations.isEmpty(),
+ showIm = uiState.imAccounts.isEmpty(),
+ showWebsites = uiState.websites.isEmpty(),
+ showSip = !uiState.showSipAddress && uiState.sipAddress.isBlank() &&
+ uiState.showSipField,
+ showNickname = !uiState.showNickname && uiState.nickname.isBlank(),
+ onAction = onAction,
+ onDismiss = { showOtherSheet = false },
+ )
+ }
+}
+
+@Composable
+private fun AccountFooterBar(
+ accountName: String?,
+ showPicker: Boolean,
+ onTap: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .then(if (showPicker) Modifier.clickable(onClick = onTap) else Modifier)
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .testTag(TestTags.ACCOUNT_FOOTER),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AnimatedContent(
+ targetState = accountName ?: "Device only",
+ transitionSpec = {
+ fadeIn(tween(200)) togetherWith fadeOut(tween(200))
+ },
+ label = "account_crossfade",
+ ) { name ->
+ Text(
+ text = "Saving to $name",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (showPicker) {
+ Spacer(Modifier.width(4.dp))
+ Icon(
+ Icons.Filled.KeyboardArrowUp,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AccountBottomSheet(
+ accounts: List,
+ selectedAccount: AccountWithDataSet?,
+ onAction: (ContactCreationAction) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ modifier = Modifier.testTag(TestTags.ACCOUNT_SHEET),
+ ) {
+ accounts.forEachIndexed { index, account ->
+ val isSelected = account == selectedAccount
+ ListItem(
+ headlineContent = { Text(account.name ?: "Device") },
+ supportingContent = {
+ val typeLabel = account.type ?: "Device"
+ Text(typeLabel)
+ },
+ trailingContent = {
+ if (isSelected) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ },
+ modifier = Modifier
+ .clickable {
+ onAction(ContactCreationAction.SelectAccount(account))
+ onDismiss()
+ }
+ .semantics {
+ role = Role.RadioButton
+ selected = isSelected
+ }
+ .testTag(TestTags.accountSheetItem(index)),
+ )
+ }
+ Spacer(Modifier.navigationBarsPadding())
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/ContactCreationViewModel.kt b/src/com/android/contacts/ui/contactcreation/ContactCreationViewModel.kt
new file mode 100644
index 000000000..97d134f8e
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/ContactCreationViewModel.kt
@@ -0,0 +1,461 @@
+package com.android.contacts.ui.contactcreation
+
+import android.content.Context
+import android.net.Uri
+import androidx.core.content.FileProvider
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.android.contacts.R
+import com.android.contacts.di.core.DefaultDispatcher
+import com.android.contacts.model.AccountTypeManager
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.ui.contactcreation.mapper.RawContactDeltaMapper
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.ContactCreationEffect
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.contactcreation.model.EventFieldState
+import com.android.contacts.ui.contactcreation.model.GroupFieldState
+import com.android.contacts.ui.contactcreation.model.ImFieldState
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.contactcreation.model.RelationFieldState
+import com.android.contacts.ui.contactcreation.model.WebsiteFieldState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+// TooManyFunctions: MVI ViewModels inherently have many functions -- one dispatcher (onAction),
+// plus private handlers for each action group, plus lifecycle/save/state helpers. This count
+// is proportional to the number of contact field types and cannot be reduced without degrading
+// readability or moving to a less explicit dispatch mechanism.
+@Suppress("TooManyFunctions")
+@HiltViewModel
+internal class ContactCreationViewModel @Inject constructor(
+ private val savedStateHandle: SavedStateHandle,
+ private val deltaMapper: RawContactDeltaMapper,
+ private val accountTypeManager: AccountTypeManager,
+ @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
+ @ApplicationContext private val appContext: Context,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(
+ savedStateHandle.get(STATE_KEY) ?: ContactCreationUiState(),
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _accounts = MutableStateFlow>(emptyList())
+ val accounts: StateFlow> = _accounts.asStateFlow()
+
+ private val _effects = Channel(Channel.BUFFERED)
+ val effects: Flow = _effects.receiveAsFlow()
+
+ init {
+ cleanupTempPhotos()
+ loadWritableAccounts()
+
+ viewModelScope.launch {
+ _uiState.collect { savedStateHandle[STATE_KEY] = it }
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun loadWritableAccounts() {
+ viewModelScope.launch(defaultDispatcher) {
+ try {
+ val filter = AccountTypeManager.insertableFilter(appContext)
+ val loaded = accountTypeManager.filterAccountsAsync(filter)
+ .get(ACCOUNT_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .map { it.account }
+ _accounts.value = loaded
+ if (_uiState.value.selectedAccount == null) {
+ loaded.firstOrNull()?.let { first ->
+ updateState {
+ copy(selectedAccount = first, accountName = first.name)
+ }
+ }
+ }
+ } catch (_: Exception) {
+ // Fallback: device-only, empty account list
+ }
+ }
+ }
+
+ fun onAction(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.NavigateBack -> handleBack()
+ is ContactCreationAction.Save -> save()
+ is ContactCreationAction.ConfirmDiscard -> confirmDiscard()
+ is ContactCreationAction.DismissDiscardDialog -> dismissDiscardDialog()
+ is ContactCreationAction.SelectAccount -> handleSelectAccount(action)
+ else -> handleSectionToggleOrFieldUpdate(action)
+ }
+ }
+
+ private fun handleSelectAccount(action: ContactCreationAction.SelectAccount) {
+ val writable = _accounts.value
+ if (writable.isEmpty() || action.account in writable) {
+ updateState {
+ copy(
+ selectedAccount = action.account,
+ accountName = action.account.name,
+ groups = emptyList(),
+ )
+ }
+ }
+ }
+
+ private fun handleSectionToggleOrFieldUpdate(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.ShowOrganization ->
+ updateState { copy(showOrganization = true) }
+ is ContactCreationAction.HideOrganization ->
+ updateState {
+ copy(showOrganization = false, organization = OrganizationFieldState())
+ }
+ is ContactCreationAction.ShowNote -> updateState { copy(showNote = true) }
+ is ContactCreationAction.HideNote -> updateState { copy(showNote = false, note = "") }
+ is ContactCreationAction.ShowNickname -> updateState { copy(showNickname = true) }
+ is ContactCreationAction.HideNickname ->
+ updateState { copy(showNickname = false, nickname = "") }
+ is ContactCreationAction.ShowSipAddress ->
+ updateState { copy(showSipAddress = true) }
+ is ContactCreationAction.HideSipAddress ->
+ updateState { copy(showSipAddress = false, sipAddress = "") }
+ is ContactCreationAction.SetPhoto -> updateState { copy(photoUri = action.uri) }
+ is ContactCreationAction.RemovePhoto -> updateState { copy(photoUri = null) }
+ is ContactCreationAction.RequestGallery ->
+ viewModelScope.launch { _effects.send(ContactCreationEffect.LaunchGallery) }
+ is ContactCreationAction.RequestCamera -> requestCamera()
+ else -> handleFieldUpdateAction(action)
+ }
+ }
+
+ /**
+ * Handles all field-value update actions: name parts, organization, note, nickname, SIP,
+ * groups, and repeatable-field CRUD (phone, email, address, event, relation, IM, website).
+ */
+ private fun handleFieldUpdateAction(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.UpdatePrefix -> updateName { copy(prefix = action.value) }
+ is ContactCreationAction.UpdateFirstName -> updateName { copy(first = action.value) }
+ is ContactCreationAction.UpdateMiddleName -> updateName { copy(middle = action.value) }
+ is ContactCreationAction.UpdateLastName -> updateName { copy(last = action.value) }
+ is ContactCreationAction.UpdateSuffix -> updateName { copy(suffix = action.value) }
+ is ContactCreationAction.UpdateCompany ->
+ updateState { copy(organization = organization.copy(company = action.value)) }
+ is ContactCreationAction.UpdateJobTitle ->
+ updateState { copy(organization = organization.copy(title = action.value)) }
+ is ContactCreationAction.UpdateNote -> updateState { copy(note = action.value) }
+ is ContactCreationAction.UpdateNickname -> updateState { copy(nickname = action.value) }
+ is ContactCreationAction.UpdateSipAddress ->
+ updateState { copy(sipAddress = action.value) }
+ is ContactCreationAction.ToggleGroup ->
+ updateState {
+ val existing = groups.find { it.groupId == action.groupId }
+ if (existing != null) {
+ copy(groups = groups.filterNot { it.groupId == action.groupId })
+ } else {
+ copy(
+ groups = groups +
+ GroupFieldState(groupId = action.groupId, title = action.title),
+ )
+ }
+ }
+ else -> handleContactInfoCrud(action)
+ }
+ }
+
+ private fun handleContactInfoCrud(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.AddPhone ->
+ updateState { copy(phoneNumbers = phoneNumbers + PhoneFieldState()) }
+ is ContactCreationAction.RemovePhone ->
+ updateState { copy(phoneNumbers = phoneNumbers.filterNot { it.id == action.id }) }
+ is ContactCreationAction.UpdatePhone ->
+ updateState {
+ copy(
+ phoneNumbers = phoneNumbers.map {
+ if (it.id == action.id) it.copy(number = action.value) else it
+ },
+ )
+ }
+ is ContactCreationAction.UpdatePhoneType ->
+ updateState {
+ copy(
+ phoneNumbers = phoneNumbers.map {
+ if (it.id == action.id) it.copy(type = action.type) else it
+ },
+ )
+ }
+ is ContactCreationAction.AddEmail ->
+ updateState { copy(emails = emails + EmailFieldState()) }
+ is ContactCreationAction.RemoveEmail ->
+ updateState { copy(emails = emails.filterNot { it.id == action.id }) }
+ is ContactCreationAction.UpdateEmail ->
+ updateState {
+ copy(
+ emails = emails.map {
+ if (it.id == action.id) it.copy(address = action.value) else it
+ },
+ )
+ }
+ is ContactCreationAction.UpdateEmailType ->
+ updateState {
+ copy(
+ emails = emails.map {
+ if (it.id == action.id) it.copy(type = action.type) else it
+ },
+ )
+ }
+ else -> handleAddressCrud(action)
+ }
+ }
+
+ private fun handleAddressCrud(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.AddAddress ->
+ updateState { copy(addresses = addresses + AddressFieldState()) }
+ is ContactCreationAction.RemoveAddress ->
+ updateState {
+ copy(addresses = addresses.filterNot { it.id == action.id })
+ }
+ is ContactCreationAction.UpdateAddressStreet,
+ is ContactCreationAction.UpdateAddressCity,
+ is ContactCreationAction.UpdateAddressRegion,
+ is ContactCreationAction.UpdateAddressPostcode,
+ is ContactCreationAction.UpdateAddressCountry,
+ is ContactCreationAction.UpdateAddressType,
+ -> handleAddressFieldUpdate(action)
+ else -> handleMoreFieldsCrud(action)
+ }
+ }
+
+ private fun handleAddressFieldUpdate(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.UpdateAddressStreet ->
+ updateAddress(action.id) { copy(street = action.value) }
+ is ContactCreationAction.UpdateAddressCity ->
+ updateAddress(action.id) { copy(city = action.value) }
+ is ContactCreationAction.UpdateAddressRegion ->
+ updateAddress(action.id) { copy(region = action.value) }
+ is ContactCreationAction.UpdateAddressPostcode ->
+ updateAddress(action.id) { copy(postcode = action.value) }
+ is ContactCreationAction.UpdateAddressCountry ->
+ updateAddress(action.id) { copy(country = action.value) }
+ is ContactCreationAction.UpdateAddressType ->
+ updateAddress(action.id) { copy(type = action.type) }
+ else -> Unit
+ }
+ }
+
+ private inline fun updateAddress(
+ id: String,
+ crossinline transform: AddressFieldState.() -> AddressFieldState,
+ ) {
+ updateState {
+ copy(addresses = addresses.map { if (it.id == id) it.transform() else it })
+ }
+ }
+
+ private fun handleMoreFieldsCrud(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.AddEvent ->
+ updateState { copy(events = events + EventFieldState()) }
+ is ContactCreationAction.RemoveEvent ->
+ updateState { copy(events = events.filterNot { it.id == action.id }) }
+ is ContactCreationAction.UpdateEvent ->
+ updateState {
+ copy(
+ events = events.map {
+ if (it.id == action.id) it.copy(startDate = action.value) else it
+ },
+ )
+ }
+ is ContactCreationAction.UpdateEventType ->
+ updateState {
+ copy(
+ events = events.map {
+ if (it.id == action.id) it.copy(type = action.type) else it
+ },
+ )
+ }
+ is ContactCreationAction.AddRelation ->
+ updateState { copy(relations = relations + RelationFieldState()) }
+ is ContactCreationAction.RemoveRelation ->
+ updateState { copy(relations = relations.filterNot { it.id == action.id }) }
+ is ContactCreationAction.UpdateRelation ->
+ updateState {
+ copy(
+ relations = relations.map {
+ if (it.id == action.id) it.copy(name = action.value) else it
+ },
+ )
+ }
+ is ContactCreationAction.UpdateRelationType ->
+ updateState {
+ copy(
+ relations = relations.map {
+ if (it.id == action.id) it.copy(type = action.type) else it
+ },
+ )
+ }
+ else -> handleImWebsiteCrud(action)
+ }
+ }
+
+ private fun handleImWebsiteCrud(action: ContactCreationAction) {
+ when (action) {
+ is ContactCreationAction.AddIm ->
+ updateState { copy(imAccounts = imAccounts + ImFieldState()) }
+ is ContactCreationAction.RemoveIm ->
+ updateState { copy(imAccounts = imAccounts.filterNot { it.id == action.id }) }
+ is ContactCreationAction.UpdateIm ->
+ updateState {
+ copy(
+ imAccounts = imAccounts.map {
+ if (it.id == action.id) it.copy(data = action.value) else it
+ },
+ )
+ }
+ is ContactCreationAction.UpdateImProtocol ->
+ updateState {
+ copy(
+ imAccounts = imAccounts.map {
+ if (it.id == action.id) it.copy(protocol = action.protocol) else it
+ },
+ )
+ }
+ is ContactCreationAction.AddWebsite ->
+ updateState { copy(websites = websites + WebsiteFieldState()) }
+ is ContactCreationAction.RemoveWebsite ->
+ updateState { copy(websites = websites.filterNot { it.id == action.id }) }
+ is ContactCreationAction.UpdateWebsite ->
+ updateState {
+ copy(
+ websites = websites.map {
+ if (it.id == action.id) it.copy(url = action.value) else it
+ },
+ )
+ }
+ is ContactCreationAction.UpdateWebsiteType ->
+ updateState {
+ copy(
+ websites = websites.map {
+ if (it.id == action.id) it.copy(type = action.type) else it
+ },
+ )
+ }
+ else -> Unit
+ }
+ }
+
+ fun onSaveResult(success: Boolean, contactUri: Uri?) {
+ viewModelScope.launch {
+ updateState { copy(isSaving = false) }
+ if (success) {
+ _effects.send(ContactCreationEffect.SaveSuccess(contactUri))
+ } else {
+ _effects.send(ContactCreationEffect.ShowError(R.string.contactSavedErrorToast))
+ }
+ }
+ }
+
+ private fun save() {
+ val state = _uiState.value
+ if (state.isSaving) return
+ if (!state.hasPendingChanges()) return
+
+ viewModelScope.launch(defaultDispatcher) {
+ updateState { copy(isSaving = true) }
+ val result = deltaMapper.map(state, state.selectedAccount)
+ _effects.send(ContactCreationEffect.Save(result))
+ }
+ }
+
+ private fun handleBack() {
+ viewModelScope.launch {
+ if (_uiState.value.hasPendingChanges()) {
+ updateState { copy(showDiscardDialog = true) }
+ } else {
+ _effects.send(ContactCreationEffect.NavigateBack)
+ }
+ }
+ }
+
+ private fun confirmDiscard() {
+ viewModelScope.launch {
+ updateState { copy(showDiscardDialog = false) }
+ _effects.send(ContactCreationEffect.NavigateBack)
+ }
+ }
+
+ private fun dismissDiscardDialog() {
+ updateState { copy(showDiscardDialog = false) }
+ }
+
+ private fun requestCamera() {
+ viewModelScope.launch(defaultDispatcher) {
+ val photoDir = File(appContext.cacheDir, PHOTO_CACHE_DIR).apply { mkdirs() }
+ val photoFile = File(photoDir, "photo_${UUID.randomUUID()}.jpg")
+ val authority = appContext.getString(R.string.contacts_file_provider_authority)
+ val uri = FileProvider.getUriForFile(appContext, authority, photoFile)
+ pendingCameraUri = uri
+ _effects.send(ContactCreationEffect.LaunchCamera(uri))
+ }
+ }
+
+ /** URI of the file passed to ACTION_IMAGE_CAPTURE, persisted across process death. */
+ internal var pendingCameraUri: Uri?
+ get() = savedStateHandle.get(PENDING_CAMERA_URI_KEY)
+ set(value) {
+ savedStateHandle[PENDING_CAMERA_URI_KEY] = value
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ cleanupTempPhotos()
+ }
+
+ private fun cleanupTempPhotos() {
+ val photoDir = File(appContext.cacheDir, PHOTO_CACHE_DIR)
+ if (photoDir.exists()) {
+ photoDir.listFiles()?.forEach { it.delete() }
+ }
+ }
+
+ private inline fun updateName(crossinline transform: NameState.() -> NameState) {
+ _uiState.update { it.copy(nameState = it.nameState.transform()) }
+ }
+
+ private inline fun updateState(
+ crossinline transform: ContactCreationUiState.() -> ContactCreationUiState,
+ ) {
+ _uiState.update { it.transform() }
+ }
+
+ internal companion object {
+ const val STATE_KEY = "state"
+ const val SAVE_COMPLETED_ACTION = "com.android.contacts.SAVE_COMPLETED"
+ const val SAVE_MODE_EXTRA_KEY = "saveMode"
+ }
+}
+
+private const val PENDING_CAMERA_URI_KEY = "pendingCameraUri"
+private const val PHOTO_CACHE_DIR = "contact_photos"
+private const val ACCOUNT_LOAD_TIMEOUT_SECONDS = 5L
diff --git a/src/com/android/contacts/ui/contactcreation/TestTags.kt b/src/com/android/contacts/ui/contactcreation/TestTags.kt
new file mode 100644
index 000000000..c12175a8c
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/TestTags.kt
@@ -0,0 +1,144 @@
+package com.android.contacts.ui.contactcreation
+
+// TooManyFunctions: TestTags is a constants registry with index-based tag factory functions
+// (e.g., phoneField(index)). The function count is 1:1 with the number of indexed UI elements
+// in the form -- splitting would scatter related tags across files for no readability gain.
+@Suppress("TooManyFunctions")
+internal object TestTags {
+ // Top-level
+ const val SAVE_BUTTON = "contact_creation_save_button"
+ const val BACK_BUTTON = "contact_creation_back_button"
+ const val CLOSE_BUTTON = "contact_creation_close_button"
+ const val SAVE_TEXT_BUTTON = "contact_creation_save_text_button"
+
+ // Section headers
+ const val SECTION_HEADER_NAME = "contact_creation_section_header_name"
+ const val SECTION_HEADER_PHONE = "contact_creation_section_header_phone"
+ const val SECTION_HEADER_EMAIL = "contact_creation_section_header_email"
+ const val SECTION_HEADER_ADDRESS = "contact_creation_section_header_address"
+ const val SECTION_HEADER_ORGANIZATION = "contact_creation_section_header_organization"
+ const val SECTION_HEADER_GROUPS = "contact_creation_section_header_groups"
+
+ // Photo background
+ const val PHOTO_BG_STRIP = "contact_creation_photo_bg_strip"
+
+ // Dividers
+ const val DIVIDER_AFTER_PHOTO = "contact_creation_divider_after_photo"
+ const val DIVIDER_AFTER_ACCOUNT = "contact_creation_divider_after_account"
+
+ // Name section
+ const val NAME_PREFIX = "contact_creation_name_prefix"
+ const val NAME_FIRST = "contact_creation_name_first"
+ const val NAME_MIDDLE = "contact_creation_name_middle"
+ const val NAME_LAST = "contact_creation_name_last"
+ const val NAME_SUFFIX = "contact_creation_name_suffix"
+
+ // Phone section
+ const val PHONE_ADD = "contact_creation_phone_add"
+ fun phoneField(index: Int): String = "contact_creation_phone_field_$index"
+ fun phoneDelete(index: Int): String = "contact_creation_phone_delete_$index"
+ fun phoneType(index: Int): String = "contact_creation_phone_type_$index"
+
+ // Email section
+ const val EMAIL_ADD = "contact_creation_email_add"
+ fun emailField(index: Int): String = "contact_creation_email_field_$index"
+ fun emailDelete(index: Int): String = "contact_creation_email_delete_$index"
+ fun emailType(index: Int): String = "contact_creation_email_type_$index"
+
+ // Address section
+ const val ADDRESS_ADD = "contact_creation_address_add"
+ fun addressStreet(index: Int): String = "contact_creation_address_street_$index"
+ fun addressCity(index: Int): String = "contact_creation_address_city_$index"
+ fun addressRegion(index: Int): String = "contact_creation_address_region_$index"
+ fun addressPostcode(index: Int): String = "contact_creation_address_postcode_$index"
+ fun addressCountry(index: Int): String = "contact_creation_address_country_$index"
+ fun addressDelete(index: Int): String = "contact_creation_address_delete_$index"
+ fun addressType(index: Int): String = "contact_creation_address_type_$index"
+
+ // Organization section
+ const val ORG_COMPANY = "contact_creation_org_company"
+ const val ORG_TITLE = "contact_creation_org_title"
+
+ // More fields section
+ const val MORE_FIELDS_TOGGLE = "contact_creation_more_fields_toggle"
+ const val MORE_FIELDS_CONTENT = "contact_creation_more_fields_content"
+
+ // Event
+ const val EVENT_ADD = "contact_creation_event_add"
+ fun eventField(index: Int): String = "contact_creation_event_field_$index"
+ fun eventDelete(index: Int): String = "contact_creation_event_delete_$index"
+ fun eventType(index: Int): String = "contact_creation_event_type_$index"
+
+ // Relation
+ const val RELATION_ADD = "contact_creation_relation_add"
+ fun relationField(index: Int): String = "contact_creation_relation_field_$index"
+ fun relationDelete(index: Int): String = "contact_creation_relation_delete_$index"
+ fun relationType(index: Int): String = "contact_creation_relation_type_$index"
+
+ // IM
+ const val IM_ADD = "contact_creation_im_add"
+ fun imField(index: Int): String = "contact_creation_im_field_$index"
+ fun imDelete(index: Int): String = "contact_creation_im_delete_$index"
+ fun imProtocol(index: Int): String = "contact_creation_im_protocol_$index"
+
+ // Website
+ const val WEBSITE_ADD = "contact_creation_website_add"
+ fun websiteField(index: Int): String = "contact_creation_website_field_$index"
+ fun websiteDelete(index: Int): String = "contact_creation_website_delete_$index"
+ fun websiteType(index: Int): String = "contact_creation_website_type_$index"
+
+ // Note
+ const val NOTE_FIELD = "contact_creation_note_field"
+
+ // Nickname
+ const val NICKNAME_FIELD = "contact_creation_nickname_field"
+
+ // SIP
+ const val SIP_FIELD = "contact_creation_sip_field"
+
+ // Group section
+ const val GROUP_SECTION = "contact_creation_group_section"
+ fun groupCheckbox(index: Int): String = "contact_creation_group_checkbox_$index"
+
+ // Account
+ const val ACCOUNT_CHIP = "contact_creation_account_chip"
+
+ // Discard dialog
+ const val DISCARD_DIALOG = "contact_creation_discard_dialog"
+ const val DISCARD_DIALOG_CONFIRM = "contact_creation_discard_dialog_confirm"
+ const val DISCARD_DIALOG_DISMISS = "contact_creation_discard_dialog_dismiss"
+
+ // Photo
+ const val PHOTO_AVATAR = "contact_creation_photo_avatar"
+ const val PHOTO_MENU = "contact_creation_photo_menu"
+ const val PHOTO_PICK_GALLERY = "contact_creation_photo_pick_gallery"
+ const val PHOTO_TAKE_CAMERA = "contact_creation_photo_take_camera"
+ const val PHOTO_REMOVE = "contact_creation_photo_remove"
+ const val PHOTO_PLACEHOLDER_ICON = "contact_creation_photo_placeholder_icon"
+
+ // Add more info chip grid
+ const val ADD_MORE_INFO_SECTION = "contact_creation_add_more_info"
+ const val OTHER_FIELDS_SHEET = "contact_creation_other_fields_sheet"
+ fun addMoreInfoChip(section: String): String = "contact_creation_add_more_info_chip_$section"
+ fun otherSheetItem(section: String): String = "contact_creation_other_sheet_item_$section"
+
+ // Remove field buttons for single-field sections
+ const val NICKNAME_REMOVE = "contact_creation_nickname_remove"
+ const val NOTE_REMOVE = "contact_creation_note_remove"
+ const val SIP_REMOVE = "contact_creation_sip_remove"
+ const val ORG_REMOVE = "contact_creation_org_remove"
+
+ // Account
+ const val ACCOUNT_FOOTER = "contact_creation_account_footer"
+ const val ACCOUNT_SHEET = "contact_creation_account_sheet"
+ fun accountSheetItem(index: Int): String = "contact_creation_account_sheet_item_$index"
+
+ // Custom label dialog
+ const val CUSTOM_LABEL_DIALOG = "custom_label_dialog"
+ const val CUSTOM_LABEL_INPUT = "custom_label_input"
+ const val CUSTOM_LABEL_OK = "custom_label_ok"
+ const val CUSTOM_LABEL_CANCEL = "custom_label_cancel"
+
+ // Field type selector dropdown options
+ fun fieldTypeOption(typeLabel: String): String = "field_type_option_$typeLabel"
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/AddMoreInfoSection.kt b/src/com/android/contacts/ui/contactcreation/component/AddMoreInfoSection.kt
new file mode 100644
index 000000000..45e760a44
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/AddMoreInfoSection.kt
@@ -0,0 +1,252 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.FlowRowScope
+import androidx.compose.foundation.layout.Spacer
+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.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Notes
+import androidx.compose.material.icons.filled.Business
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.core.isReduceMotionEnabled
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+internal fun AddMoreInfoSection(
+ showAddressChip: Boolean,
+ showOrgChip: Boolean,
+ showNoteChip: Boolean,
+ showGroupsChip: Boolean,
+ showOtherChip: Boolean,
+ onAddAddress: () -> Unit,
+ onShowOrganization: () -> Unit,
+ onShowNote: () -> Unit,
+ onShowGroups: () -> Unit,
+ onShowOtherSheet: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val reduceMotion = isReduceMotionEnabled()
+ val enterSpec = chipEnterTransition(reduceMotion)
+ val exitSpec = chipExitTransition(reduceMotion)
+
+ val chipItems = buildChipItems(
+ showAddressChip = showAddressChip,
+ showOrgChip = showOrgChip,
+ showNoteChip = showNoteChip,
+ showGroupsChip = showGroupsChip,
+ showOtherChip = showOtherChip,
+ onAddAddress = onAddAddress,
+ onShowOrganization = onShowOrganization,
+ onShowNote = onShowNote,
+ onShowGroups = onShowGroups,
+ onShowOtherSheet = onShowOtherSheet,
+ )
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier
+ .padding(horizontal = 8.dp, vertical = 16.dp)
+ .testTag(TestTags.ADD_MORE_INFO_SECTION)
+ .animateContentSize(
+ animationSpec = if (reduceMotion) {
+ snap()
+ } else {
+ spring(stiffness = Spring.StiffnessMediumLow)
+ },
+ ),
+ ) {
+ Text(
+ text = stringResource(R.string.contact_creation_add_more_info),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(16.dp))
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ maxItemsInEachRow = 2,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ chipItems.forEach { item ->
+ AnimatedChipSlot(
+ visible = item.visible,
+ enterSpec = enterSpec,
+ exitSpec = exitSpec,
+ label = item.label,
+ icon = item.icon,
+ section = item.section,
+ onClick = item.onClick,
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun FlowRowScope.AnimatedChipSlot(
+ visible: Boolean,
+ enterSpec: EnterTransition,
+ exitSpec: ExitTransition,
+ label: String,
+ icon: ImageVector,
+ section: String,
+ onClick: () -> Unit,
+) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = enterSpec,
+ exit = exitSpec,
+ modifier = Modifier.weight(1f),
+ ) {
+ ChipButton(label = label, icon = icon, section = section, onClick = onClick)
+ }
+}
+
+private fun chipEnterTransition(reduceMotion: Boolean): EnterTransition {
+ if (reduceMotion) return EnterTransition.None
+ return expandHorizontally(
+ spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ ) + fadeIn(
+ spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ )
+}
+
+private fun chipExitTransition(reduceMotion: Boolean): ExitTransition {
+ if (reduceMotion) return ExitTransition.None
+ return shrinkHorizontally(
+ spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ ) + fadeOut(
+ spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ )
+}
+
+private data class ChipItemData(
+ val visible: Boolean,
+ val label: String,
+ val icon: ImageVector,
+ val section: String,
+ val onClick: () -> Unit,
+)
+
+@Composable
+private fun buildChipItems(
+ showAddressChip: Boolean,
+ showOrgChip: Boolean,
+ showNoteChip: Boolean,
+ showGroupsChip: Boolean,
+ showOtherChip: Boolean,
+ onAddAddress: () -> Unit,
+ onShowOrganization: () -> Unit,
+ onShowNote: () -> Unit,
+ onShowGroups: () -> Unit,
+ onShowOtherSheet: () -> Unit,
+): List = listOf(
+ ChipItemData(
+ visible = showAddressChip,
+ label = stringResource(R.string.contact_creation_section_address),
+ icon = Icons.Filled.LocationOn,
+ section = "address",
+ onClick = onAddAddress,
+ ),
+ ChipItemData(
+ visible = showOrgChip,
+ label = stringResource(R.string.contact_creation_section_organization),
+ icon = Icons.Filled.Business,
+ section = "organization",
+ onClick = onShowOrganization,
+ ),
+ ChipItemData(
+ visible = showNoteChip,
+ label = stringResource(R.string.contact_creation_note),
+ icon = Icons.AutoMirrored.Filled.Notes,
+ section = "note",
+ onClick = onShowNote,
+ ),
+ ChipItemData(
+ visible = showGroupsChip,
+ label = stringResource(R.string.contact_creation_groups),
+ icon = Icons.Filled.Group,
+ section = "groups",
+ onClick = onShowGroups,
+ ),
+ ChipItemData(
+ visible = showOtherChip,
+ label = stringResource(R.string.contact_creation_other),
+ icon = Icons.Filled.MoreVert,
+ section = "other",
+ onClick = onShowOtherSheet,
+ ),
+)
+
+@Composable
+private fun ChipButton(
+ label: String,
+ icon: ImageVector,
+ section: String,
+ onClick: () -> Unit,
+) {
+ FilledTonalButton(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.addMoreInfoChip(section)),
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(label, modifier = Modifier.padding(vertical = 10.dp))
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/AddressSection.kt b/src/com/android/contacts/ui/contactcreation/component/AddressSection.kt
new file mode 100644
index 000000000..eb42ff008
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/AddressSection.kt
@@ -0,0 +1,195 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.core.isReduceMotionEnabled
+
+/**
+ * Address section as a @Composable for Column-based layout.
+ */
+@Composable
+internal fun AddressSectionContent(
+ addresses: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val reduceMotion = isReduceMotionEnabled()
+ Column(
+ modifier = modifier.animateContentSize(
+ animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
+ ),
+ ) {
+ addresses.forEachIndexed { index, address ->
+ if (index > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ val visibleState = remember {
+ MutableTransitionState(false).apply { targetState = true }
+ }
+ AnimatedVisibility(
+ visibleState = visibleState,
+ enter = if (reduceMotion) EnterTransition.None else expandVertically() + fadeIn(),
+ ) {
+ AddressFieldRow(
+ address = address,
+ index = index,
+ onAction = onAction,
+ )
+ }
+ }
+ AddRemoveFieldRow(
+ addLabel = stringResource(R.string.contact_creation_add_address),
+ onAdd = { onAction(ContactCreationAction.AddAddress) },
+ addTestTag = TestTags.ADDRESS_ADD,
+ removeLabel = if (addresses.isNotEmpty()) {
+ stringResource(R.string.contact_creation_remove_address)
+ } else {
+ null
+ },
+ onRemove = if (addresses.isNotEmpty()) {
+ { onAction(ContactCreationAction.RemoveAddress(addresses.last().id)) }
+ } else {
+ null
+ },
+ )
+ }
+}
+
+@Composable
+internal fun AddressFieldRow(
+ address: AddressFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var showCustomDialog by remember { mutableStateOf(false) }
+
+ FieldRow(modifier = modifier) {
+ AddressFieldColumns(
+ address = address,
+ index = index,
+ onAction = onAction,
+ onRequestCustomLabel = { showCustomDialog = true },
+ )
+ }
+
+ if (showCustomDialog) {
+ CustomLabelDialog(
+ onConfirm = { label ->
+ showCustomDialog = false
+ onAction(
+ ContactCreationAction.UpdateAddressType(
+ address.id,
+ AddressType.Custom(label),
+ ),
+ )
+ },
+ onDismiss = { showCustomDialog = false },
+ )
+ }
+}
+
+@Composable
+private fun AddressFieldColumns(
+ address: AddressFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ onRequestCustomLabel: () -> Unit,
+) {
+ Column {
+ val context = LocalContext.current
+ val selectorLabels = AddressType.selectorTypes.map { it.label(context) }
+ FieldTypeSelector(
+ currentLabel = address.type.label(context),
+ labels = selectorLabels,
+ onIndexSelected = { idx ->
+ val selected = AddressType.selectorTypes[idx]
+ if (selected is AddressType.Custom && selected.label.isEmpty()) {
+ onRequestCustomLabel()
+ } else {
+ onAction(ContactCreationAction.UpdateAddressType(address.id, selected))
+ }
+ },
+ modifier = Modifier.testTag(TestTags.addressType(index)),
+ )
+ AddressTextField(
+ address.street,
+ stringResource(R.string.postal_street),
+ TestTags.addressStreet(index),
+ ) {
+ onAction(ContactCreationAction.UpdateAddressStreet(address.id, it))
+ }
+ AddressTextField(
+ address.city,
+ stringResource(R.string.postal_city),
+ TestTags.addressCity(index),
+ ) {
+ onAction(ContactCreationAction.UpdateAddressCity(address.id, it))
+ }
+ AddressTextField(
+ address.region,
+ stringResource(R.string.postal_region),
+ TestTags.addressRegion(index),
+ ) {
+ onAction(ContactCreationAction.UpdateAddressRegion(address.id, it))
+ }
+ AddressTextField(
+ address.postcode,
+ stringResource(R.string.postal_postcode),
+ TestTags.addressPostcode(index),
+ ) {
+ onAction(ContactCreationAction.UpdateAddressPostcode(address.id, it))
+ }
+ AddressTextField(
+ address.country,
+ stringResource(R.string.postal_country),
+ TestTags.addressCountry(index),
+ ) {
+ onAction(ContactCreationAction.UpdateAddressCountry(address.id, it))
+ }
+ }
+}
+
+@Composable
+private fun AddressTextField(
+ value: String,
+ label: String,
+ tag: String,
+ onValueChange: (String) -> Unit,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(tag),
+ singleLine = true,
+ )
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/CustomLabelDialog.kt b/src/com/android/contacts/ui/contactcreation/component/CustomLabelDialog.kt
new file mode 100644
index 000000000..d67f8bc91
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/CustomLabelDialog.kt
@@ -0,0 +1,64 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+
+@Composable
+internal fun CustomLabelDialog(
+ onConfirm: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var label by remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.testTag(TestTags.CUSTOM_LABEL_DIALOG),
+ title = { Text(stringResource(R.string.contact_creation_custom_label_title)) },
+ text = {
+ OutlinedTextField(
+ value = label,
+ onValueChange = { label = it },
+ label = { Text(stringResource(R.string.contact_creation_custom_label_hint)) },
+ singleLine = true,
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .testTag(TestTags.CUSTOM_LABEL_INPUT),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(label) },
+ enabled = label.isNotBlank(),
+ modifier = Modifier.testTag(TestTags.CUSTOM_LABEL_OK),
+ ) {
+ Text(stringResource(android.R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ modifier = Modifier.testTag(TestTags.CUSTOM_LABEL_CANCEL),
+ ) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
+ )
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/EmailSection.kt b/src/com/android/contacts/ui/contactcreation/component/EmailSection.kt
new file mode 100644
index 000000000..8b34fe36c
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/EmailSection.kt
@@ -0,0 +1,187 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.core.isReduceMotionEnabled
+
+/**
+ * Email section as a @Composable for Column-based layout.
+ */
+@Composable
+internal fun EmailSectionContent(
+ emails: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val reduceMotion = isReduceMotionEnabled()
+ Column(
+ modifier = modifier.animateContentSize(
+ animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
+ ),
+ ) {
+ emails.forEachIndexed { index, email ->
+ if (index > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ val visibleState = remember {
+ MutableTransitionState(false).apply { targetState = true }
+ }
+ AnimatedVisibility(
+ visibleState = visibleState,
+ enter = if (reduceMotion) EnterTransition.None else expandVertically() + fadeIn(),
+ ) {
+ EmailFieldRow(
+ email = email,
+ index = index,
+ onAction = onAction,
+ )
+ }
+ }
+ AddRemoveFieldRow(
+ addLabel = stringResource(R.string.contact_creation_add_email),
+ onAdd = { onAction(ContactCreationAction.AddEmail) },
+ addTestTag = TestTags.EMAIL_ADD,
+ removeLabel = if (emails.size > 1) {
+ stringResource(R.string.contact_creation_remove_email)
+ } else {
+ null
+ },
+ onRemove = if (emails.size > 1) {
+ { onAction(ContactCreationAction.RemoveEmail(emails.last().id)) }
+ } else {
+ null
+ },
+ )
+ }
+}
+
+@Composable
+internal fun EmailFieldRow(
+ email: EmailFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var showCustomDialog by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+ val focusManager = LocalFocusManager.current
+ val currentTypeLabel = email.type.label(context)
+
+ FieldRow(modifier = modifier) {
+ OutlinedTextField(
+ value = email.address,
+ onValueChange = { onAction(ContactCreationAction.UpdateEmail(email.id, it)) },
+ label = {
+ Text("${stringResource(R.string.emailLabelsGroup)} ($currentTypeLabel)")
+ },
+ trailingIcon = {
+ EmailTypeDropdown(
+ index = index,
+ onTypeSelected = { type ->
+ if (type is EmailType.Custom && type.label.isEmpty()) {
+ showCustomDialog = true
+ } else {
+ onAction(ContactCreationAction.UpdateEmailType(email.id, type))
+ }
+ },
+ )
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) },
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.emailField(index)),
+ singleLine = true,
+ )
+ }
+
+ if (showCustomDialog) {
+ CustomLabelDialog(
+ onConfirm = { label ->
+ showCustomDialog = false
+ onAction(ContactCreationAction.UpdateEmailType(email.id, EmailType.Custom(label)))
+ },
+ onDismiss = { showCustomDialog = false },
+ )
+ }
+}
+
+@Composable
+private fun EmailTypeDropdown(
+ index: Int,
+ onTypeSelected: (EmailType) -> Unit,
+) {
+ var typeExpanded by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+ val selectorLabels = remember { EmailType.selectorTypes.map { it.label(context) } }
+
+ IconButton(
+ onClick = { typeExpanded = true },
+ modifier = Modifier.testTag(TestTags.emailType(index)),
+ ) {
+ Icon(
+ Icons.Filled.ArrowDropDown,
+ contentDescription = stringResource(R.string.contact_creation_change_type),
+ )
+ }
+ DropdownMenu(
+ expanded = typeExpanded,
+ onDismissRequest = { typeExpanded = false },
+ ) {
+ EmailType.selectorTypes.forEachIndexed { i, type ->
+ DropdownMenuItem(
+ text = { Text(selectorLabels[i]) },
+ onClick = {
+ typeExpanded = false
+ onTypeSelected(type)
+ },
+ modifier = Modifier.testTag(
+ TestTags.fieldTypeOption(selectorLabels[i])
+ ),
+ )
+ }
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/EventSection.kt b/src/com/android/contacts/ui/contactcreation/component/EventSection.kt
new file mode 100644
index 000000000..69fc8e656
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/EventSection.kt
@@ -0,0 +1,77 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.EventFieldState
+
+/**
+ * Event section as a @Composable for Column-based layout.
+ */
+@Composable
+internal fun EventSectionContent(
+ events: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ events.forEachIndexed { index, event ->
+ if (index > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ EventFieldRow(
+ event = event,
+ index = index,
+ onAction = onAction,
+ )
+ }
+ AddRemoveFieldRow(
+ addLabel = stringResource(R.string.contact_creation_add_event),
+ onAdd = { onAction(ContactCreationAction.AddEvent) },
+ addTestTag = TestTags.EVENT_ADD,
+ removeLabel = if (events.size > 1) {
+ stringResource(R.string.contact_creation_remove_event)
+ } else {
+ null
+ },
+ onRemove = if (events.size > 1) {
+ { onAction(ContactCreationAction.RemoveEvent(events.last().id)) }
+ } else {
+ null
+ },
+ )
+ }
+}
+
+@Composable
+private fun EventFieldRow(
+ event: EventFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FieldRow(
+ modifier = modifier,
+ ) {
+ OutlinedTextField(
+ value = event.startDate,
+ onValueChange = { onAction(ContactCreationAction.UpdateEvent(event.id, it)) },
+ label = { Text(stringResource(R.string.contact_creation_date)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.eventField(index)),
+ singleLine = true,
+ )
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/FieldType.kt b/src/com/android/contacts/ui/contactcreation/component/FieldType.kt
new file mode 100644
index 000000000..da6542d10
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/FieldType.kt
@@ -0,0 +1,246 @@
+package com.android.contacts.ui.contactcreation.component
+
+import android.os.Parcelable
+import android.provider.ContactsContract.CommonDataKinds.Email
+import android.provider.ContactsContract.CommonDataKinds.Event
+import android.provider.ContactsContract.CommonDataKinds.Im
+import android.provider.ContactsContract.CommonDataKinds.Phone
+import android.provider.ContactsContract.CommonDataKinds.Relation
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal
+import android.provider.ContactsContract.CommonDataKinds.Website
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import com.android.contacts.R
+import kotlinx.parcelize.Parcelize
+
+@Stable
+@Parcelize
+internal sealed class PhoneType : Parcelable {
+ data object Mobile : PhoneType()
+ data object Home : PhoneType()
+ data object Work : PhoneType()
+ data object WorkMobile : PhoneType()
+ data object Main : PhoneType()
+ data object FaxWork : PhoneType()
+ data object FaxHome : PhoneType()
+ data object Pager : PhoneType()
+ data object Other : PhoneType()
+ data class Custom(val label: String) : PhoneType()
+
+ val rawValue: Int
+ get() = when (this) {
+ is Mobile -> Phone.TYPE_MOBILE
+ is Home -> Phone.TYPE_HOME
+ is Work -> Phone.TYPE_WORK
+ is WorkMobile -> Phone.TYPE_WORK_MOBILE
+ is Main -> Phone.TYPE_MAIN
+ is FaxWork -> Phone.TYPE_FAX_WORK
+ is FaxHome -> Phone.TYPE_FAX_HOME
+ is Pager -> Phone.TYPE_PAGER
+ is Other -> Phone.TYPE_OTHER
+ is Custom -> Phone.TYPE_CUSTOM
+ }
+
+ companion object {
+ val selectorTypes: List = listOf(
+ Mobile, Home, Work, WorkMobile, Main,
+ FaxWork, FaxHome, Pager, Other, Custom(""),
+ )
+ }
+}
+
+@Stable
+@Parcelize
+internal sealed class EmailType : Parcelable {
+ data object Home : EmailType()
+ data object Work : EmailType()
+ data object Other : EmailType()
+ data object Mobile : EmailType()
+ data class Custom(val label: String) : EmailType()
+
+ val rawValue: Int
+ get() = when (this) {
+ is Home -> Email.TYPE_HOME
+ is Work -> Email.TYPE_WORK
+ is Other -> Email.TYPE_OTHER
+ is Mobile -> Email.TYPE_MOBILE
+ is Custom -> Email.TYPE_CUSTOM
+ }
+
+ companion object {
+ val selectorTypes: List = listOf(
+ Home,
+ Work,
+ Other,
+ Mobile,
+ Custom(""),
+ )
+ }
+}
+
+@Stable
+@Parcelize
+internal sealed class AddressType : Parcelable {
+ data object Home : AddressType()
+ data object Work : AddressType()
+ data object Other : AddressType()
+ data class Custom(val label: String) : AddressType()
+
+ val rawValue: Int
+ get() = when (this) {
+ is Home -> StructuredPostal.TYPE_HOME
+ is Work -> StructuredPostal.TYPE_WORK
+ is Other -> StructuredPostal.TYPE_OTHER
+ is Custom -> StructuredPostal.TYPE_CUSTOM
+ }
+
+ companion object {
+ val selectorTypes: List = listOf(
+ Home,
+ Work,
+ Other,
+ Custom(""),
+ )
+ }
+}
+
+@Stable
+@Parcelize
+internal sealed class EventType : Parcelable {
+ data object Birthday : EventType()
+ data object Anniversary : EventType()
+ data object Other : EventType()
+ data class Custom(val label: String) : EventType()
+
+ val rawValue: Int
+ get() = when (this) {
+ is Birthday -> Event.TYPE_BIRTHDAY
+ is Anniversary -> Event.TYPE_ANNIVERSARY
+ is Other -> Event.TYPE_OTHER
+ is Custom -> Event.TYPE_CUSTOM
+ }
+}
+
+@Stable
+@Parcelize
+internal sealed class RelationType : Parcelable {
+ data object Assistant : RelationType()
+ data object Brother : RelationType()
+ data object Child : RelationType()
+ data object DomesticPartner : RelationType()
+ data object Father : RelationType()
+ data object Friend : RelationType()
+ data object Manager : RelationType()
+ data object Mother : RelationType()
+ data object Parent : RelationType()
+ data object Partner : RelationType()
+ data object Sister : RelationType()
+ data object Spouse : RelationType()
+ data object Relative : RelationType()
+ data object ReferredBy : RelationType()
+ data class Custom(val label: String) : RelationType()
+
+ val rawValue: Int
+ get() = when (this) {
+ is Assistant -> Relation.TYPE_ASSISTANT
+ is Brother -> Relation.TYPE_BROTHER
+ is Child -> Relation.TYPE_CHILD
+ is DomesticPartner -> Relation.TYPE_DOMESTIC_PARTNER
+ is Father -> Relation.TYPE_FATHER
+ is Friend -> Relation.TYPE_FRIEND
+ is Manager -> Relation.TYPE_MANAGER
+ is Mother -> Relation.TYPE_MOTHER
+ is Parent -> Relation.TYPE_PARENT
+ is Partner -> Relation.TYPE_PARTNER
+ is Sister -> Relation.TYPE_SISTER
+ is Spouse -> Relation.TYPE_SPOUSE
+ is Relative -> Relation.TYPE_RELATIVE
+ is ReferredBy -> Relation.TYPE_REFERRED_BY
+ is Custom -> Relation.TYPE_CUSTOM
+ }
+}
+
+@Stable
+@Parcelize
+internal sealed class ImProtocol : Parcelable {
+ data object Aim : ImProtocol()
+ data object Msn : ImProtocol()
+ data object Yahoo : ImProtocol()
+ data object Skype : ImProtocol()
+ data object Qq : ImProtocol()
+ data object GoogleTalk : ImProtocol()
+ data object Icq : ImProtocol()
+ data object Jabber : ImProtocol()
+ data class Custom(val label: String) : ImProtocol()
+
+ val rawValue: Int
+ get() = when (this) {
+ is Aim -> Im.PROTOCOL_AIM
+ is Msn -> Im.PROTOCOL_MSN
+ is Yahoo -> Im.PROTOCOL_YAHOO
+ is Skype -> Im.PROTOCOL_SKYPE
+ is Qq -> Im.PROTOCOL_QQ
+ is GoogleTalk -> Im.PROTOCOL_GOOGLE_TALK
+ is Icq -> Im.PROTOCOL_ICQ
+ is Jabber -> Im.PROTOCOL_JABBER
+ is Custom -> Im.PROTOCOL_CUSTOM
+ }
+}
+
+@Stable
+@Parcelize
+internal sealed class WebsiteType : Parcelable {
+ data object Homepage : WebsiteType()
+ data object Blog : WebsiteType()
+ data object Profile : WebsiteType()
+ data object Home : WebsiteType()
+ data object Work : WebsiteType()
+ data object Ftp : WebsiteType()
+ data object Other : WebsiteType()
+ data class Custom(val label: String) : WebsiteType()
+
+ val rawValue: Int
+ get() = when (this) {
+ is Homepage -> Website.TYPE_HOMEPAGE
+ is Blog -> Website.TYPE_BLOG
+ is Profile -> Website.TYPE_PROFILE
+ is Home -> Website.TYPE_HOME
+ is Work -> Website.TYPE_WORK
+ is Ftp -> Website.TYPE_FTP
+ is Other -> Website.TYPE_OTHER
+ is Custom -> Website.TYPE_CUSTOM
+ }
+}
+
+// Receiver is nullable because Scaffold's SubcomposeLayout can null-out captured
+// sealed-class instances at the JVM level despite Kotlin's non-null types.
+internal fun PhoneType?.label(context: android.content.Context): String = when (this) {
+ is PhoneType.Mobile -> context.getString(R.string.field_type_mobile)
+ is PhoneType.Home -> context.getString(R.string.field_type_home)
+ is PhoneType.Work -> context.getString(R.string.field_type_work)
+ is PhoneType.WorkMobile -> context.getString(R.string.field_type_work_mobile)
+ is PhoneType.Main -> context.getString(R.string.field_type_main)
+ is PhoneType.FaxWork -> context.getString(R.string.field_type_fax_work)
+ is PhoneType.FaxHome -> context.getString(R.string.field_type_fax_home)
+ is PhoneType.Pager -> context.getString(R.string.field_type_pager)
+ is PhoneType.Other -> context.getString(R.string.field_type_other)
+ is PhoneType.Custom -> label.ifEmpty { context.getString(R.string.field_type_custom) }
+ null -> context.getString(R.string.field_type_mobile)
+}
+
+internal fun EmailType?.label(context: android.content.Context): String = when (this) {
+ is EmailType.Home -> context.getString(R.string.field_type_home)
+ is EmailType.Work -> context.getString(R.string.field_type_work)
+ is EmailType.Other -> context.getString(R.string.field_type_other)
+ is EmailType.Mobile -> context.getString(R.string.field_type_mobile)
+ is EmailType.Custom -> label.ifEmpty { context.getString(R.string.field_type_custom) }
+ null -> context.getString(R.string.field_type_home)
+}
+
+internal fun AddressType?.label(context: android.content.Context): String = when (this) {
+ is AddressType.Home -> context.getString(R.string.field_type_home)
+ is AddressType.Work -> context.getString(R.string.field_type_work)
+ is AddressType.Other -> context.getString(R.string.field_type_other)
+ is AddressType.Custom -> label.ifEmpty { context.getString(R.string.field_type_custom) }
+ null -> context.getString(R.string.field_type_home)
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/FieldTypeSelector.kt b/src/com/android/contacts/ui/contactcreation/component/FieldTypeSelector.kt
new file mode 100644
index 000000000..fb7e4ec27
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/FieldTypeSelector.kt
@@ -0,0 +1,85 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.core.AppTheme
+
+/**
+ * Generic type selector with FilterChip + DropdownMenu.
+ *
+ * Dispatches by index via [onIndexSelected] to avoid sealed-class instances
+ * becoming null inside DropdownMenu's separate Popup composition tree.
+ */
+@Composable
+internal fun FieldTypeSelector(
+ currentLabel: String,
+ labels: List,
+ onIndexSelected: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ Box(modifier = modifier) {
+ FilterChip(
+ selected = true,
+ onClick = { expanded = true },
+ label = {
+ Text(
+ currentLabel,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ },
+ trailingIcon = {
+ Icon(
+ Icons.Filled.ArrowDropDown,
+ contentDescription = null,
+ )
+ },
+ )
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ labels.forEachIndexed { index, label ->
+ DropdownMenuItem(
+ text = { Text(label) },
+ onClick = {
+ expanded = false
+ onIndexSelected(index)
+ },
+ modifier = Modifier.testTag(TestTags.fieldTypeOption(label)),
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun FieldTypeSelectorPreview() {
+ AppTheme {
+ val labels = listOf("Mobile", "Home", "Work", "Other")
+ FieldTypeSelector(
+ currentLabel = "Mobile",
+ labels = labels,
+ onIndexSelected = {},
+ )
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/GroupSection.kt b/src/com/android/contacts/ui/contactcreation/component/GroupSection.kt
new file mode 100644
index 000000000..78ef24425
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/GroupSection.kt
@@ -0,0 +1,66 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.GroupFieldState
+import com.android.contacts.ui.contactcreation.model.GroupInfo
+
+/**
+ * Group section as a @Composable for Column-based layout.
+ * Uses FieldRow for each group row.
+ */
+@Composable
+internal fun GroupSectionContent(
+ availableGroups: List,
+ selectedGroups: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (availableGroups.isEmpty()) return
+
+ Column(modifier = modifier.testTag(TestTags.GROUP_SECTION)) {
+ availableGroups.forEachIndexed { index, group ->
+ FieldRow {
+ GroupCheckboxRow(
+ group = group,
+ isSelected = selectedGroups.any { it.groupId == group.groupId },
+ index = index,
+ onAction = onAction,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun GroupCheckboxRow(
+ group: GroupInfo,
+ isSelected: Boolean,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = {
+ onAction(ContactCreationAction.ToggleGroup(group.groupId, group.title))
+ },
+ modifier = Modifier.testTag(TestTags.groupCheckbox(index)),
+ )
+ Text(text = group.title)
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/ImSection.kt b/src/com/android/contacts/ui/contactcreation/component/ImSection.kt
new file mode 100644
index 000000000..32453e02b
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/ImSection.kt
@@ -0,0 +1,77 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.ImFieldState
+
+/**
+ * IM section as a @Composable for Column-based layout.
+ */
+@Composable
+internal fun ImSectionContent(
+ imAccounts: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ imAccounts.forEachIndexed { index, im ->
+ if (index > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ ImFieldRow(
+ im = im,
+ index = index,
+ onAction = onAction,
+ )
+ }
+ AddRemoveFieldRow(
+ addLabel = stringResource(R.string.contact_creation_add_im),
+ onAdd = { onAction(ContactCreationAction.AddIm) },
+ addTestTag = TestTags.IM_ADD,
+ removeLabel = if (imAccounts.size > 1) {
+ stringResource(R.string.contact_creation_remove_im)
+ } else {
+ null
+ },
+ onRemove = if (imAccounts.size > 1) {
+ { onAction(ContactCreationAction.RemoveIm(imAccounts.last().id)) }
+ } else {
+ null
+ },
+ )
+ }
+}
+
+@Composable
+private fun ImFieldRow(
+ im: ImFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FieldRow(
+ modifier = modifier,
+ ) {
+ OutlinedTextField(
+ value = im.data,
+ onValueChange = { onAction(ContactCreationAction.UpdateIm(im.id, it)) },
+ label = { Text(stringResource(R.string.imLabelsGroup)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.imField(index)),
+ singleLine = true,
+ )
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/MoreFieldsSection.kt b/src/com/android/contacts/ui/contactcreation/component/MoreFieldsSection.kt
new file mode 100644
index 000000000..4f7f1b56e
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/MoreFieldsSection.kt
@@ -0,0 +1,72 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+
+/**
+ * Standalone single-field composables previously housed inside the "More Fields" section.
+ * Now used directly by the editor screen with individual show/hide visibility.
+ */
+
+@Composable
+internal fun NicknameField(
+ nickname: String,
+ onAction: (ContactCreationAction) -> Unit,
+) {
+ FieldRow {
+ OutlinedTextField(
+ value = nickname,
+ onValueChange = { onAction(ContactCreationAction.UpdateNickname(it)) },
+ label = { Text(stringResource(R.string.nicknameLabelsGroup)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.NICKNAME_FIELD),
+ singleLine = true,
+ )
+ }
+}
+
+@Composable
+internal fun NoteField(
+ note: String,
+ onAction: (ContactCreationAction) -> Unit,
+) {
+ FieldRow {
+ OutlinedTextField(
+ value = note,
+ onValueChange = { onAction(ContactCreationAction.UpdateNote(it)) },
+ label = { Text(stringResource(R.string.contact_creation_note)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.NOTE_FIELD),
+ singleLine = false,
+ maxLines = 4,
+ )
+ }
+}
+
+@Composable
+internal fun SipField(
+ sipAddress: String,
+ onAction: (ContactCreationAction) -> Unit,
+) {
+ FieldRow {
+ OutlinedTextField(
+ value = sipAddress,
+ onValueChange = { onAction(ContactCreationAction.UpdateSipAddress(it)) },
+ label = { Text(stringResource(R.string.contact_creation_sip)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.SIP_FIELD),
+ singleLine = true,
+ )
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/NameSection.kt b/src/com/android/contacts/ui/contactcreation/component/NameSection.kt
new file mode 100644
index 000000000..35f5c884f
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/NameSection.kt
@@ -0,0 +1,54 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.NameState
+
+/**
+ * Name section as a @Composable for Column-based layout.
+ * Uses FieldRow for each name field.
+ */
+@Composable
+internal fun NameSectionContent(
+ nameState: NameState,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ FieldRow {
+ OutlinedTextField(
+ value = nameState.first,
+ onValueChange = { onAction(ContactCreationAction.UpdateFirstName(it)) },
+ label = { Text(stringResource(R.string.contact_creation_first_name)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.NAME_FIRST),
+ singleLine = true,
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ FieldRow {
+ OutlinedTextField(
+ value = nameState.last,
+ onValueChange = { onAction(ContactCreationAction.UpdateLastName(it)) },
+ label = { Text(stringResource(R.string.contact_creation_last_name)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.NAME_LAST),
+ singleLine = true,
+ )
+ }
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/OrganizationSection.kt b/src/com/android/contacts/ui/contactcreation/component/OrganizationSection.kt
new file mode 100644
index 000000000..adaae45dc
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/OrganizationSection.kt
@@ -0,0 +1,54 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+
+/**
+ * Organization section as a @Composable for Column-based layout.
+ * Uses FieldRow for each organization field.
+ */
+@Composable
+internal fun OrganizationSectionContent(
+ organization: OrganizationFieldState,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ FieldRow {
+ OutlinedTextField(
+ value = organization.company,
+ onValueChange = { onAction(ContactCreationAction.UpdateCompany(it)) },
+ label = { Text(stringResource(R.string.contact_creation_company)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.ORG_COMPANY),
+ singleLine = true,
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ FieldRow {
+ OutlinedTextField(
+ value = organization.title,
+ onValueChange = { onAction(ContactCreationAction.UpdateJobTitle(it)) },
+ label = { Text(stringResource(R.string.contact_creation_job_title)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.ORG_TITLE),
+ singleLine = true,
+ )
+ }
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/OtherFieldsBottomSheet.kt b/src/com/android/contacts/ui/contactcreation/component/OtherFieldsBottomSheet.kt
new file mode 100644
index 000000000..36c82b33a
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/OtherFieldsBottomSheet.kt
@@ -0,0 +1,143 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Message
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.People
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+
+private data class OtherFieldEntry(
+ val visible: Boolean,
+ val label: String,
+ val icon: ImageVector,
+ val section: String,
+ val action: ContactCreationAction,
+)
+
+@Composable
+internal fun OtherFieldsBottomSheet(
+ showEvents: Boolean,
+ showRelations: Boolean,
+ showIm: Boolean,
+ showWebsites: Boolean,
+ showSip: Boolean,
+ showNickname: Boolean,
+ onAction: (ContactCreationAction) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState()
+ val entries = buildOtherFieldEntries(
+ showEvents = showEvents,
+ showRelations = showRelations,
+ showIm = showIm,
+ showWebsites = showWebsites,
+ showSip = showSip,
+ showNickname = showNickname,
+ )
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = modifier.testTag(TestTags.OTHER_FIELDS_SHEET),
+ ) {
+ entries.filter { it.visible }.forEach { entry ->
+ SheetItem(
+ label = entry.label,
+ icon = entry.icon,
+ section = entry.section,
+ onClick = {
+ onAction(entry.action)
+ onDismiss()
+ },
+ )
+ }
+ }
+}
+
+private fun buildOtherFieldEntries(
+ showEvents: Boolean,
+ showRelations: Boolean,
+ showIm: Boolean,
+ showWebsites: Boolean,
+ showSip: Boolean,
+ showNickname: Boolean,
+): List = listOf(
+ OtherFieldEntry(
+ showEvents,
+ "Event",
+ Icons.Filled.DateRange,
+ "event",
+ ContactCreationAction.AddEvent,
+ ),
+ OtherFieldEntry(
+ showRelations,
+ "Relation",
+ Icons.Filled.People,
+ "relation",
+ ContactCreationAction.AddRelation,
+ ),
+ OtherFieldEntry(
+ showIm,
+ "Instant messaging",
+ Icons.AutoMirrored.Filled.Message,
+ "im",
+ ContactCreationAction.AddIm,
+ ),
+ OtherFieldEntry(
+ showWebsites,
+ "Website",
+ Icons.Filled.Public,
+ "website",
+ ContactCreationAction.AddWebsite,
+ ),
+ OtherFieldEntry(
+ showSip,
+ "SIP address",
+ Icons.Filled.Phone,
+ "sip",
+ ContactCreationAction.ShowSipAddress,
+ ),
+ OtherFieldEntry(
+ showNickname,
+ "Nickname",
+ Icons.Filled.Person,
+ "nickname",
+ ContactCreationAction.ShowNickname,
+ ),
+)
+
+@Composable
+private fun SheetItem(
+ label: String,
+ icon: ImageVector,
+ section: String,
+ onClick: () -> Unit,
+) {
+ ListItem(
+ headlineContent = { Text(label) },
+ leadingContent = { Icon(icon, contentDescription = null) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .testTag(TestTags.otherSheetItem(section)),
+ )
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/PhoneSection.kt b/src/com/android/contacts/ui/contactcreation/component/PhoneSection.kt
new file mode 100644
index 000000000..b478729ac
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/PhoneSection.kt
@@ -0,0 +1,186 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.core.isReduceMotionEnabled
+
+/**
+ * Phone section as a @Composable for Column-based layout.
+ */
+@Composable
+internal fun PhoneSectionContent(
+ phones: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val reduceMotion = isReduceMotionEnabled()
+ Column(
+ modifier = modifier.animateContentSize(
+ animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
+ ),
+ ) {
+ phones.forEachIndexed { index, phone ->
+ if (index > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ val visibleState = remember {
+ MutableTransitionState(false).apply { targetState = true }
+ }
+ AnimatedVisibility(
+ visibleState = visibleState,
+ enter = if (reduceMotion) EnterTransition.None else expandVertically() + fadeIn(),
+ ) {
+ PhoneFieldRow(
+ phone = phone,
+ index = index,
+ onAction = onAction,
+ )
+ }
+ }
+ AddRemoveFieldRow(
+ addLabel = stringResource(R.string.contact_creation_add_phone),
+ onAdd = { onAction(ContactCreationAction.AddPhone) },
+ addTestTag = TestTags.PHONE_ADD,
+ removeLabel = if (phones.size > 1) {
+ stringResource(R.string.contact_creation_remove_phone)
+ } else {
+ null
+ },
+ onRemove = if (phones.size > 1) {
+ { onAction(ContactCreationAction.RemovePhone(phones.last().id)) }
+ } else {
+ null
+ },
+ )
+ }
+}
+
+@Composable
+internal fun PhoneFieldRow(
+ phone: PhoneFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var showCustomDialog by remember { mutableStateOf(false) }
+ var typeExpanded by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+ val focusManager = LocalFocusManager.current
+ val selectorLabels = remember { PhoneType.selectorTypes.map { it.label(context) } }
+ val currentTypeLabel = phone.type.label(context)
+
+ FieldRow(modifier = modifier) {
+ OutlinedTextField(
+ value = phone.number,
+ onValueChange = { onAction(ContactCreationAction.UpdatePhone(phone.id, it)) },
+ label = {
+ Text("${stringResource(R.string.phoneLabelsGroup)} ($currentTypeLabel)")
+ },
+ trailingIcon = {
+ PhoneTypeSelector(
+ index = index,
+ selectorLabels = selectorLabels,
+ expanded = typeExpanded,
+ onExpand = { typeExpanded = true },
+ onDismiss = { typeExpanded = false },
+ onTypeSelected = { type ->
+ typeExpanded = false
+ if (type is PhoneType.Custom && type.label.isEmpty()) {
+ showCustomDialog = true
+ } else {
+ onAction(ContactCreationAction.UpdatePhoneType(phone.id, type))
+ }
+ },
+ )
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Phone,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) },
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.phoneField(index)),
+ singleLine = true,
+ )
+ }
+
+ if (showCustomDialog) {
+ CustomLabelDialog(
+ onConfirm = { label ->
+ showCustomDialog = false
+ onAction(ContactCreationAction.UpdatePhoneType(phone.id, PhoneType.Custom(label)))
+ },
+ onDismiss = { showCustomDialog = false },
+ )
+ }
+}
+
+@Composable
+private fun PhoneTypeSelector(
+ index: Int,
+ selectorLabels: List,
+ expanded: Boolean,
+ onExpand: () -> Unit,
+ onDismiss: () -> Unit,
+ onTypeSelected: (PhoneType) -> Unit,
+) {
+ IconButton(
+ onClick = onExpand,
+ modifier = Modifier.testTag(TestTags.phoneType(index)),
+ ) {
+ Icon(
+ Icons.Filled.ArrowDropDown,
+ contentDescription = stringResource(R.string.contact_creation_change_type),
+ )
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
+ PhoneType.selectorTypes.forEachIndexed { i, type ->
+ DropdownMenuItem(
+ text = { Text(selectorLabels[i]) },
+ onClick = { onTypeSelected(type) },
+ modifier = Modifier.testTag(TestTags.fieldTypeOption(selectorLabels[i])),
+ )
+ }
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/PhotoSection.kt b/src/com/android/contacts/ui/contactcreation/component/PhotoSection.kt
new file mode 100644
index 000000000..274d55891
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/PhotoSection.kt
@@ -0,0 +1,257 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package com.android.contacts.ui.contactcreation.component
+
+import android.net.Uri
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import coil3.size.Size
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.core.isReduceMotionEnabled
+import kotlinx.coroutines.launch
+
+private const val AVATAR_SIZE_DP = 120
+private const val PHOTO_DOWNSAMPLE_PX = 360 // 120dp * 3 (xxxhdpi)
+private const val PLACEHOLDER_ICON_SIZE_DP = 56
+private const val MORPHED_CORNER_DP = 20
+private const val BG_STRIP_HEIGHT_DP = 168
+private const val CAMERA_BADGE_SIZE_DP = 32
+private const val CAMERA_BADGE_ICON_SIZE_DP = 16
+
+/**
+ * Photo section as a @Composable (for Column-based layout).
+ * 120dp circle centered on plain surface. Tap opens ModalBottomSheet.
+ */
+@Composable
+internal fun PhotoSectionContent(
+ photoUri: Uri?,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ PhotoAvatar(
+ photoUri = photoUri,
+ onAction = onAction,
+ modifier = modifier.fillMaxWidth(),
+ )
+}
+
+@Composable
+internal fun PhotoAvatar(
+ photoUri: Uri?,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var showSheet by remember { mutableStateOf(false) }
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+
+ val reduceMotion = isReduceMotionEnabled()
+ val cornerRadius by animateDpAsState(
+ targetValue = if (isPressed) MORPHED_CORNER_DP.dp else (AVATAR_SIZE_DP / 2).dp,
+ animationSpec = if (reduceMotion) {
+ snap()
+ } else {
+ spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ )
+ },
+ label = "avatar_shape_morph",
+ )
+ val morphedShape = RoundedCornerShape(cornerRadius)
+
+ Box(
+ modifier = modifier
+ .height(BG_STRIP_HEIGHT_DP.dp)
+ .testTag(TestTags.PHOTO_BG_STRIP),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box {
+ Surface(
+ modifier = Modifier
+ .size(AVATAR_SIZE_DP.dp)
+ .clip(morphedShape)
+ .testTag(TestTags.PHOTO_AVATAR)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ ) {
+ showSheet = true
+ },
+ shape = morphedShape,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ if (photoUri != null) {
+ PhotoImage(photoUri)
+ } else {
+ PlaceholderIcon()
+ }
+ }
+ CameraBadge(modifier = Modifier.align(Alignment.BottomEnd))
+ }
+ }
+
+ if (showSheet) {
+ PhotoBottomSheet(
+ hasPhoto = photoUri != null,
+ onAction = onAction,
+ onDismiss = { showSheet = false },
+ )
+ }
+}
+
+@Composable
+private fun CameraBadge(modifier: Modifier = Modifier) {
+ Surface(
+ modifier = modifier
+ .size(CAMERA_BADGE_SIZE_DP.dp)
+ .offset(x = (-4).dp, y = (-4).dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Icons.Filled.CameraAlt,
+ contentDescription = null,
+ modifier = Modifier.size(CAMERA_BADGE_ICON_SIZE_DP.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PhotoBottomSheet(
+ hasPhoto: Boolean,
+ onAction: (ContactCreationAction) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+
+ fun dismissAndDo(action: ContactCreationAction) {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ onDismiss()
+ onAction(action)
+ }
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = Modifier.testTag(TestTags.PHOTO_MENU),
+ ) {
+ Text(
+ text = stringResource(R.string.contact_creation_contact_photo),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.take_photo)) },
+ leadingContent = {
+ Icon(Icons.Filled.CameraAlt, contentDescription = null)
+ },
+ modifier = Modifier
+ .clickable { dismissAndDo(ContactCreationAction.RequestCamera) }
+ .testTag(TestTags.PHOTO_TAKE_CAMERA),
+ )
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.contact_creation_choose_photo)) },
+ leadingContent = {
+ Icon(Icons.Filled.Image, contentDescription = null)
+ },
+ modifier = Modifier
+ .clickable { dismissAndDo(ContactCreationAction.RequestGallery) }
+ .testTag(TestTags.PHOTO_PICK_GALLERY),
+ )
+ if (hasPhoto) {
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.removePhoto)) },
+ leadingContent = {
+ Icon(Icons.Filled.Delete, contentDescription = null)
+ },
+ modifier = Modifier
+ .clickable { dismissAndDo(ContactCreationAction.RemovePhoto) }
+ .testTag(TestTags.PHOTO_REMOVE),
+ )
+ }
+ Spacer(Modifier.navigationBarsPadding())
+ }
+}
+
+@Composable
+private fun PhotoImage(photoUri: Uri) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(photoUri)
+ .size(Size(PHOTO_DOWNSAMPLE_PX, PHOTO_DOWNSAMPLE_PX))
+ .crossfade(true)
+ .build(),
+ contentDescription = stringResource(R.string.contact_creation_contact_photo),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(AVATAR_SIZE_DP.dp),
+ )
+}
+
+@Composable
+private fun PlaceholderIcon() {
+ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(AVATAR_SIZE_DP.dp)) {
+ Icon(
+ imageVector = Icons.Filled.Person,
+ contentDescription = stringResource(R.string.contact_creation_add_photo),
+ modifier = Modifier
+ .size(PLACEHOLDER_ICON_SIZE_DP.dp)
+ .testTag(TestTags.PHOTO_PLACEHOLDER_ICON),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/RelationSection.kt b/src/com/android/contacts/ui/contactcreation/component/RelationSection.kt
new file mode 100644
index 000000000..2c82a442d
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/RelationSection.kt
@@ -0,0 +1,77 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.RelationFieldState
+
+/**
+ * Relation section as a @Composable for Column-based layout.
+ */
+@Composable
+internal fun RelationSectionContent(
+ relations: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ relations.forEachIndexed { index, relation ->
+ if (index > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ RelationFieldRow(
+ relation = relation,
+ index = index,
+ onAction = onAction,
+ )
+ }
+ AddRemoveFieldRow(
+ addLabel = stringResource(R.string.contact_creation_add_relation),
+ onAdd = { onAction(ContactCreationAction.AddRelation) },
+ addTestTag = TestTags.RELATION_ADD,
+ removeLabel = if (relations.size > 1) {
+ stringResource(R.string.contact_creation_remove_relation)
+ } else {
+ null
+ },
+ onRemove = if (relations.size > 1) {
+ { onAction(ContactCreationAction.RemoveRelation(relations.last().id)) }
+ } else {
+ null
+ },
+ )
+ }
+}
+
+@Composable
+private fun RelationFieldRow(
+ relation: RelationFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FieldRow(
+ modifier = modifier,
+ ) {
+ OutlinedTextField(
+ value = relation.name,
+ onValueChange = { onAction(ContactCreationAction.UpdateRelation(relation.id, it)) },
+ label = { Text(stringResource(R.string.relationLabelsGroup)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.relationField(index)),
+ singleLine = true,
+ )
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/SharedComponents.kt b/src/com/android/contacts/ui/contactcreation/component/SharedComponents.kt
new file mode 100644
index 000000000..b892f00be
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/SharedComponents.kt
@@ -0,0 +1,128 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+
+/**
+ * Field row with 4dp vertical padding.
+ */
+@Composable
+internal fun FieldRow(
+ modifier: Modifier = Modifier,
+ trailing: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(modifier = Modifier.weight(1f)) {
+ content()
+ }
+ if (trailing != null) {
+ trailing()
+ }
+ }
+}
+
+/**
+ * Row with "Add X" at start and optional "Remove X" at end.
+ * Used for repeatable field sections (phone, email, address, etc.).
+ */
+@Composable
+internal fun AddRemoveFieldRow(
+ addLabel: String,
+ onAdd: () -> Unit,
+ modifier: Modifier = Modifier,
+ addTestTag: String = "",
+ removeLabel: String? = null,
+ onRemove: (() -> Unit)? = null,
+ removeTestTag: String = "",
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = addLabel,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .clickable(onClick = onAdd)
+ .padding(horizontal = 8.dp, vertical = 8.dp)
+ .then(if (addTestTag.isNotEmpty()) Modifier.testTag(addTestTag) else Modifier),
+ )
+ if (removeLabel != null && onRemove != null) {
+ Text(
+ text = removeLabel,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier
+ .clickable(onClick = onRemove)
+ .padding(horizontal = 8.dp, vertical = 8.dp)
+ .then(
+ if (removeTestTag.isNotEmpty()) {
+ Modifier.testTag(removeTestTag)
+ } else {
+ Modifier
+ },
+ ),
+ )
+ }
+ }
+}
+
+/**
+ * Simple add-field text link. For sections without remove capability.
+ */
+@Composable
+internal fun AddFieldButton(
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ testTag: String = "",
+) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = modifier
+ .clickable(onClick = onClick)
+ .padding(horizontal = 8.dp, vertical = 8.dp)
+ .then(if (testTag.isNotEmpty()) Modifier.testTag(testTag) else Modifier),
+ )
+}
+
+/**
+ * Text-based remove button in error color.
+ * Used for single-instance optional sections (org, nickname, sip, note).
+ */
+@Composable
+internal fun RemoveFieldButton(
+ onClick: () -> Unit,
+ contentDescription: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = contentDescription,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.error,
+ modifier = modifier
+ .clickable(onClick = onClick)
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ )
+}
diff --git a/src/com/android/contacts/ui/contactcreation/component/WebsiteSection.kt b/src/com/android/contacts/ui/contactcreation/component/WebsiteSection.kt
new file mode 100644
index 000000000..07a1b5995
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/component/WebsiteSection.kt
@@ -0,0 +1,77 @@
+package com.android.contacts.ui.contactcreation.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.android.contacts.R
+import com.android.contacts.ui.contactcreation.TestTags
+import com.android.contacts.ui.contactcreation.model.ContactCreationAction
+import com.android.contacts.ui.contactcreation.model.WebsiteFieldState
+
+/**
+ * Website section as a @Composable for Column-based layout.
+ */
+@Composable
+internal fun WebsiteSectionContent(
+ websites: List,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ websites.forEachIndexed { index, website ->
+ if (index > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ WebsiteFieldRow(
+ website = website,
+ index = index,
+ onAction = onAction,
+ )
+ }
+ AddRemoveFieldRow(
+ addLabel = stringResource(R.string.contact_creation_add_website),
+ onAdd = { onAction(ContactCreationAction.AddWebsite) },
+ addTestTag = TestTags.WEBSITE_ADD,
+ removeLabel = if (websites.size > 1) {
+ stringResource(R.string.contact_creation_remove_website)
+ } else {
+ null
+ },
+ onRemove = if (websites.size > 1) {
+ { onAction(ContactCreationAction.RemoveWebsite(websites.last().id)) }
+ } else {
+ null
+ },
+ )
+ }
+}
+
+@Composable
+private fun WebsiteFieldRow(
+ website: WebsiteFieldState,
+ index: Int,
+ onAction: (ContactCreationAction) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FieldRow(
+ modifier = modifier,
+ ) {
+ OutlinedTextField(
+ value = website.url,
+ onValueChange = { onAction(ContactCreationAction.UpdateWebsite(website.id, it)) },
+ label = { Text(stringResource(R.string.websiteLabelsGroup)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(TestTags.websiteField(index)),
+ singleLine = true,
+ )
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/di/ContactCreationProvidesModule.kt b/src/com/android/contacts/ui/contactcreation/di/ContactCreationProvidesModule.kt
new file mode 100644
index 000000000..6d04af0b5
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/di/ContactCreationProvidesModule.kt
@@ -0,0 +1,25 @@
+package com.android.contacts.ui.contactcreation.di
+
+import android.content.Context
+import com.android.contacts.model.AccountTypeManager
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object ContactCreationProvidesModule {
+
+ // AccountTypeManager.getInstance() returns an app-global singleton.
+ // @Singleton matches the actual lifecycle of the underlying Java object.
+ // ViewModelScoped or ActivityScoped would create new instances that
+ // just delegate to the same singleton, adding indirection for no benefit.
+ @Provides
+ @Singleton
+ fun provideAccountTypeManager(
+ @ApplicationContext context: Context,
+ ): AccountTypeManager = AccountTypeManager.getInstance(context)
+}
diff --git a/src/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapper.kt b/src/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapper.kt
new file mode 100644
index 000000000..d7874e51e
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapper.kt
@@ -0,0 +1,292 @@
+package com.android.contacts.ui.contactcreation.mapper
+
+import android.content.ContentValues
+import android.os.Bundle
+import android.provider.ContactsContract.CommonDataKinds.Email
+import android.provider.ContactsContract.CommonDataKinds.Event
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership
+import android.provider.ContactsContract.CommonDataKinds.Im
+import android.provider.ContactsContract.CommonDataKinds.Nickname
+import android.provider.ContactsContract.CommonDataKinds.Note
+import android.provider.ContactsContract.CommonDataKinds.Organization
+import android.provider.ContactsContract.CommonDataKinds.Phone
+import android.provider.ContactsContract.CommonDataKinds.Relation
+import android.provider.ContactsContract.CommonDataKinds.SipAddress
+import android.provider.ContactsContract.CommonDataKinds.StructuredName
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal
+import android.provider.ContactsContract.CommonDataKinds.Website
+import android.provider.ContactsContract.Data
+import com.android.contacts.model.RawContact
+import com.android.contacts.model.RawContactDelta
+import com.android.contacts.model.RawContactDeltaList
+import com.android.contacts.model.ValuesDelta
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.ui.contactcreation.component.AddressType
+import com.android.contacts.ui.contactcreation.component.EmailType
+import com.android.contacts.ui.contactcreation.component.EventType
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.component.RelationType
+import com.android.contacts.ui.contactcreation.component.WebsiteType
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import javax.inject.Inject
+
+internal data class DeltaMapperResult(val state: RawContactDeltaList, val updatedPhotos: Bundle)
+
+// TooManyFunctions: One private mapXxx method per ContactsContract MIME type (name, phone,
+// email, address, organization, event, relation, IM, website, note, nickname, SIP, group,
+// photo). Merging them would create a single unreadable method; splitting into separate
+// mapper classes would break the single-responsibility of "UiState -> RawContactDelta".
+@Suppress("TooManyFunctions")
+internal class RawContactDeltaMapper @Inject constructor() {
+
+ fun map(
+ uiState: ContactCreationUiState,
+ account: AccountWithDataSet?,
+ ): DeltaMapperResult {
+ val rawContact = RawContact().apply {
+ if (account != null) setAccount(account) else setAccountToLocal()
+ }
+ val delta = RawContactDelta(ValuesDelta.fromAfter(rawContact.values))
+ val updatedPhotos = Bundle()
+
+ mapName(delta, uiState)
+ mapPhones(delta, uiState)
+ mapEmails(delta, uiState)
+ mapAddresses(delta, uiState)
+ mapOrganization(delta, uiState)
+ mapEvents(delta, uiState)
+ mapRelations(delta, uiState)
+ mapImAccounts(delta, uiState)
+ mapWebsites(delta, uiState)
+ mapNote(delta, uiState)
+ mapNickname(delta, uiState)
+ mapSipAddress(delta, uiState)
+ mapGroups(delta, uiState)
+ mapPhoto(delta, uiState, updatedPhotos)
+
+ val state = RawContactDeltaList().apply { add(delta) }
+ return DeltaMapperResult(state = state, updatedPhotos = updatedPhotos)
+ }
+
+ private fun mapName(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ val name = uiState.nameState
+ if (!name.hasData()) return
+
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(StructuredName.CONTENT_ITEM_TYPE) {
+ putIfNotBlank(StructuredName.PREFIX, name.prefix)
+ putIfNotBlank(StructuredName.GIVEN_NAME, name.first)
+ putIfNotBlank(StructuredName.MIDDLE_NAME, name.middle)
+ putIfNotBlank(StructuredName.FAMILY_NAME, name.last)
+ putIfNotBlank(StructuredName.SUFFIX, name.suffix)
+ },
+ ),
+ )
+ }
+
+ private fun mapPhones(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (phone in uiState.phoneNumbers) {
+ if (phone.number.isBlank()) continue
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Phone.CONTENT_ITEM_TYPE) {
+ put(Phone.NUMBER, phone.number)
+ put(Phone.TYPE, phone.type.rawValue)
+ if (phone.type is PhoneType.Custom) {
+ put(Phone.LABEL, phone.type.label)
+ }
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapEmails(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (email in uiState.emails) {
+ if (email.address.isBlank()) continue
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Email.CONTENT_ITEM_TYPE) {
+ put(Email.DATA, email.address)
+ put(Email.TYPE, email.type.rawValue)
+ if (email.type is EmailType.Custom) {
+ put(Email.LABEL, email.type.label)
+ }
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapAddresses(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (address in uiState.addresses) {
+ if (!address.hasData()) continue
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(StructuredPostal.CONTENT_ITEM_TYPE) {
+ putIfNotBlank(StructuredPostal.STREET, address.street)
+ putIfNotBlank(StructuredPostal.CITY, address.city)
+ putIfNotBlank(StructuredPostal.REGION, address.region)
+ putIfNotBlank(StructuredPostal.POSTCODE, address.postcode)
+ putIfNotBlank(StructuredPostal.COUNTRY, address.country)
+ put(StructuredPostal.TYPE, address.type.rawValue)
+ if (address.type is AddressType.Custom) {
+ put(StructuredPostal.LABEL, address.type.label)
+ }
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapOrganization(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ val org = uiState.organization
+ if (!org.hasData()) return
+
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Organization.CONTENT_ITEM_TYPE) {
+ putIfNotBlank(Organization.COMPANY, org.company)
+ putIfNotBlank(Organization.TITLE, org.title)
+ },
+ ),
+ )
+ }
+
+ private fun mapEvents(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (event in uiState.events) {
+ if (event.startDate.isBlank()) continue
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Event.CONTENT_ITEM_TYPE) {
+ put(Event.START_DATE, event.startDate)
+ put(Event.TYPE, event.type.rawValue)
+ if (event.type is EventType.Custom) {
+ put(Event.LABEL, event.type.label)
+ }
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapRelations(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (relation in uiState.relations) {
+ if (relation.name.isBlank()) continue
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Relation.CONTENT_ITEM_TYPE) {
+ put(Relation.NAME, relation.name)
+ put(Relation.TYPE, relation.type.rawValue)
+ if (relation.type is RelationType.Custom) {
+ put(Relation.LABEL, relation.type.label)
+ }
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapImAccounts(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (im in uiState.imAccounts) {
+ if (im.data.isBlank()) continue
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Im.CONTENT_ITEM_TYPE) {
+ put(Im.DATA, im.data)
+ put(Im.PROTOCOL, im.protocol.rawValue)
+ if (im.protocol is ImProtocol.Custom) {
+ put(Im.CUSTOM_PROTOCOL, im.protocol.label)
+ }
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapWebsites(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (website in uiState.websites) {
+ if (website.url.isBlank()) continue
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Website.CONTENT_ITEM_TYPE) {
+ put(Website.URL, website.url)
+ put(Website.TYPE, website.type.rawValue)
+ if (website.type is WebsiteType.Custom) {
+ put(Website.LABEL, website.type.label)
+ }
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapNote(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ if (uiState.note.isBlank()) return
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Note.CONTENT_ITEM_TYPE) {
+ put(Note.NOTE, uiState.note)
+ },
+ ),
+ )
+ }
+
+ private fun mapNickname(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ if (uiState.nickname.isBlank()) return
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(Nickname.CONTENT_ITEM_TYPE) {
+ put(Nickname.NAME, uiState.nickname)
+ },
+ ),
+ )
+ }
+
+ private fun mapSipAddress(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ if (uiState.sipAddress.isBlank()) return
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(SipAddress.CONTENT_ITEM_TYPE) {
+ put(SipAddress.SIP_ADDRESS, uiState.sipAddress)
+ },
+ ),
+ )
+ }
+
+ private fun mapGroups(delta: RawContactDelta, uiState: ContactCreationUiState) {
+ for (group in uiState.groups) {
+ delta.addEntry(
+ ValuesDelta.fromAfter(
+ contentValues(GroupMembership.CONTENT_ITEM_TYPE) {
+ put(GroupMembership.GROUP_ROW_ID, group.groupId)
+ },
+ ),
+ )
+ }
+ }
+
+ private fun mapPhoto(
+ delta: RawContactDelta,
+ uiState: ContactCreationUiState,
+ updatedPhotos: Bundle,
+ ) {
+ val photoUri = uiState.photoUri ?: return
+ val tempId = delta.values.id
+ updatedPhotos.putParcelable(tempId.toString(), photoUri)
+ }
+
+ private inline fun contentValues(
+ mimeType: String,
+ block: ContentValues.() -> Unit,
+ ): ContentValues = ContentValues().apply {
+ put(Data.MIMETYPE, mimeType)
+ block()
+ }
+
+ private fun ContentValues.putIfNotBlank(key: String, value: String) {
+ if (value.isNotBlank()) put(key, value)
+ }
+}
diff --git a/src/com/android/contacts/ui/contactcreation/model/ContactCreationAction.kt b/src/com/android/contacts/ui/contactcreation/model/ContactCreationAction.kt
new file mode 100644
index 000000000..fa0a38267
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/model/ContactCreationAction.kt
@@ -0,0 +1,112 @@
+package com.android.contacts.ui.contactcreation.model
+
+import android.net.Uri
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.ui.contactcreation.component.AddressType
+import com.android.contacts.ui.contactcreation.component.EmailType
+import com.android.contacts.ui.contactcreation.component.EventType
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.component.RelationType
+import com.android.contacts.ui.contactcreation.component.WebsiteType
+
+// TooManyFunctions: Detekt counts each nested data class/object as a "function" in a sealed
+// interface. This sealed type is the exhaustive action catalogue for the MVI pattern -- one
+// subtype per user interaction. Splitting into sub-sealed types would break exhaustive `when`
+// dispatch in the ViewModel without meaningful complexity reduction.
+@Suppress("TooManyFunctions")
+internal sealed interface ContactCreationAction {
+ // Navigation
+ data object NavigateBack : ContactCreationAction
+ data object Save : ContactCreationAction
+ data object ConfirmDiscard : ContactCreationAction
+ data object DismissDiscardDialog : ContactCreationAction
+
+ // Name
+ data class UpdatePrefix(val value: String) : ContactCreationAction
+ data class UpdateFirstName(val value: String) : ContactCreationAction
+ data class UpdateMiddleName(val value: String) : ContactCreationAction
+ data class UpdateLastName(val value: String) : ContactCreationAction
+ data class UpdateSuffix(val value: String) : ContactCreationAction
+
+ // Phone
+ data object AddPhone : ContactCreationAction
+ data class RemovePhone(val id: String) : ContactCreationAction
+ data class UpdatePhone(val id: String, val value: String) : ContactCreationAction
+ data class UpdatePhoneType(val id: String, val type: PhoneType) : ContactCreationAction
+
+ // Email
+ data object AddEmail : ContactCreationAction
+ data class RemoveEmail(val id: String) : ContactCreationAction
+ data class UpdateEmail(val id: String, val value: String) : ContactCreationAction
+ data class UpdateEmailType(val id: String, val type: EmailType) : ContactCreationAction
+
+ // Address
+ data object AddAddress : ContactCreationAction
+ data class RemoveAddress(val id: String) : ContactCreationAction
+ data class UpdateAddressStreet(val id: String, val value: String) : ContactCreationAction
+ data class UpdateAddressCity(val id: String, val value: String) : ContactCreationAction
+ data class UpdateAddressRegion(val id: String, val value: String) : ContactCreationAction
+ data class UpdateAddressPostcode(val id: String, val value: String) : ContactCreationAction
+ data class UpdateAddressCountry(val id: String, val value: String) : ContactCreationAction
+ data class UpdateAddressType(val id: String, val type: AddressType) : ContactCreationAction
+
+ // Organization
+ data class UpdateCompany(val value: String) : ContactCreationAction
+ data class UpdateJobTitle(val value: String) : ContactCreationAction
+
+ // Event
+ data object AddEvent : ContactCreationAction
+ data class RemoveEvent(val id: String) : ContactCreationAction
+ data class UpdateEvent(val id: String, val value: String) : ContactCreationAction
+ data class UpdateEventType(val id: String, val type: EventType) : ContactCreationAction
+
+ // Relation
+ data object AddRelation : ContactCreationAction
+ data class RemoveRelation(val id: String) : ContactCreationAction
+ data class UpdateRelation(val id: String, val value: String) : ContactCreationAction
+ data class UpdateRelationType(val id: String, val type: RelationType) : ContactCreationAction
+
+ // IM
+ data object AddIm : ContactCreationAction
+ data class RemoveIm(val id: String) : ContactCreationAction
+ data class UpdateIm(val id: String, val value: String) : ContactCreationAction
+ data class UpdateImProtocol(val id: String, val protocol: ImProtocol) : ContactCreationAction
+
+ // Website
+ data object AddWebsite : ContactCreationAction
+ data class RemoveWebsite(val id: String) : ContactCreationAction
+ data class UpdateWebsite(val id: String, val value: String) : ContactCreationAction
+ data class UpdateWebsiteType(val id: String, val type: WebsiteType) : ContactCreationAction
+
+ // Note
+ data class UpdateNote(val value: String) : ContactCreationAction
+
+ // Nickname
+ data class UpdateNickname(val value: String) : ContactCreationAction
+
+ // SIP
+ data class UpdateSipAddress(val value: String) : ContactCreationAction
+
+ // Groups
+ data class ToggleGroup(val groupId: Long, val title: String) : ContactCreationAction
+
+ // Section visibility
+ data object ShowOrganization : ContactCreationAction
+ data object HideOrganization : ContactCreationAction
+ data object ShowNote : ContactCreationAction
+ data object HideNote : ContactCreationAction
+ data object ShowNickname : ContactCreationAction
+ data object HideNickname : ContactCreationAction
+ data object ShowSipAddress : ContactCreationAction
+ data object HideSipAddress : ContactCreationAction
+
+ // Photo
+ data class SetPhoto(val uri: Uri) : ContactCreationAction
+ data object RemovePhoto : ContactCreationAction
+ data object RequestGallery : ContactCreationAction
+ data object RequestCamera : ContactCreationAction
+
+ // Account
+ data class SelectAccount(val account: AccountWithDataSet) : ContactCreationAction
+}
diff --git a/src/com/android/contacts/ui/contactcreation/model/ContactCreationEffect.kt b/src/com/android/contacts/ui/contactcreation/model/ContactCreationEffect.kt
new file mode 100644
index 000000000..dbc2093a4
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/model/ContactCreationEffect.kt
@@ -0,0 +1,13 @@
+package com.android.contacts.ui.contactcreation.model
+
+import android.net.Uri
+import com.android.contacts.ui.contactcreation.mapper.DeltaMapperResult
+
+internal sealed interface ContactCreationEffect {
+ data class Save(val result: DeltaMapperResult) : ContactCreationEffect
+ data class SaveSuccess(val contactUri: Uri?) : ContactCreationEffect
+ data class ShowError(val messageResId: Int) : ContactCreationEffect
+ data object NavigateBack : ContactCreationEffect
+ data object LaunchGallery : ContactCreationEffect
+ data class LaunchCamera(val outputUri: Uri) : ContactCreationEffect
+}
diff --git a/src/com/android/contacts/ui/contactcreation/model/ContactCreationUiState.kt b/src/com/android/contacts/ui/contactcreation/model/ContactCreationUiState.kt
new file mode 100644
index 000000000..7726c3306
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/model/ContactCreationUiState.kt
@@ -0,0 +1,157 @@
+package com.android.contacts.ui.contactcreation.model
+
+import android.net.Uri
+import android.os.Parcelable
+import androidx.compose.runtime.Immutable
+import com.android.contacts.model.account.AccountWithDataSet
+import com.android.contacts.ui.contactcreation.component.AddressType
+import com.android.contacts.ui.contactcreation.component.EmailType
+import com.android.contacts.ui.contactcreation.component.EventType
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.component.RelationType
+import com.android.contacts.ui.contactcreation.component.WebsiteType
+import java.util.UUID
+import kotlinx.parcelize.Parcelize
+
+@Immutable
+@Parcelize
+internal data class ContactCreationUiState(
+ val nameState: NameState = NameState(),
+ val phoneNumbers: List = listOf(PhoneFieldState()),
+ val emails: List = listOf(EmailFieldState()),
+ val addresses: List = emptyList(),
+ val organization: OrganizationFieldState = OrganizationFieldState(),
+ val events: List = emptyList(),
+ val relations: List = emptyList(),
+ val imAccounts: List = emptyList(),
+ val websites: List = emptyList(),
+ val note: String = "",
+ val nickname: String = "",
+ val sipAddress: String = "",
+ val groups: List = emptyList(),
+ val availableGroups: List = emptyList(),
+ val photoUri: Uri? = null,
+ val selectedAccount: AccountWithDataSet? = null,
+ val accountName: String? = null,
+ val isSaving: Boolean = false,
+ val showOrganization: Boolean = false,
+ val showNote: Boolean = false,
+ val showNickname: Boolean = false,
+ val showSipAddress: Boolean = false,
+ val showSipField: Boolean = true,
+ val showDiscardDialog: Boolean = false,
+) : Parcelable {
+ fun hasPendingChanges(): Boolean =
+ nameState.hasData() ||
+ phoneNumbers.any { it.number.isNotBlank() } ||
+ emails.any { it.address.isNotBlank() } ||
+ addresses.any { it.hasData() } ||
+ organization.hasData() ||
+ events.any { it.startDate.isNotBlank() } ||
+ relations.any { it.name.isNotBlank() } ||
+ imAccounts.any { it.data.isNotBlank() } ||
+ websites.any { it.url.isNotBlank() } ||
+ note.isNotBlank() ||
+ nickname.isNotBlank() ||
+ sipAddress.isNotBlank() ||
+ groups.isNotEmpty() ||
+ photoUri != null
+
+ val showAddressChip: Boolean get() = addresses.isEmpty()
+
+ val showOrgChip: Boolean
+ get() = !showOrganization && organization.company.isBlank() && organization.title.isBlank()
+
+ val showNoteChip: Boolean get() = !showNote && note.isBlank()
+
+ val showGroupsChip: Boolean get() = groups.isEmpty() && availableGroups.isNotEmpty()
+
+ @Suppress("ComplexCondition")
+ val showOtherChip: Boolean
+ get() = events.isEmpty() || relations.isEmpty() || imAccounts.isEmpty() ||
+ websites.isEmpty() || (!showNickname && nickname.isBlank()) ||
+ (!showSipAddress && sipAddress.isBlank() && showSipField)
+
+ val hasAnyChip: Boolean
+ get() = showAddressChip || showOrgChip || showNoteChip || showGroupsChip || showOtherChip
+}
+
+@Immutable
+@Parcelize
+internal data class PhoneFieldState(
+ val id: String = UUID.randomUUID().toString(),
+ val number: String = "",
+ val type: PhoneType = PhoneType.Mobile,
+) : Parcelable
+
+@Immutable
+@Parcelize
+internal data class EmailFieldState(
+ val id: String = UUID.randomUUID().toString(),
+ val address: String = "",
+ val type: EmailType = EmailType.Home,
+) : Parcelable
+
+@Immutable
+@Parcelize
+internal data class AddressFieldState(
+ val id: String = UUID.randomUUID().toString(),
+ val street: String = "",
+ val city: String = "",
+ val region: String = "",
+ val postcode: String = "",
+ val country: String = "",
+ val type: AddressType = AddressType.Home,
+) : Parcelable {
+ fun hasData(): Boolean =
+ street.isNotBlank() || city.isNotBlank() || region.isNotBlank() ||
+ postcode.isNotBlank() || country.isNotBlank()
+}
+
+@Immutable
+@Parcelize
+internal data class OrganizationFieldState(val company: String = "", val title: String = "") :
+ Parcelable {
+ fun hasData(): Boolean = company.isNotBlank() || title.isNotBlank()
+}
+
+@Immutable
+@Parcelize
+internal data class EventFieldState(
+ val id: String = UUID.randomUUID().toString(),
+ val startDate: String = "",
+ val type: EventType = EventType.Birthday,
+) : Parcelable
+
+@Immutable
+@Parcelize
+internal data class RelationFieldState(
+ val id: String = UUID.randomUUID().toString(),
+ val name: String = "",
+ val type: RelationType = RelationType.Spouse,
+) : Parcelable
+
+@Immutable
+@Parcelize
+internal data class ImFieldState(
+ val id: String = UUID.randomUUID().toString(),
+ val data: String = "",
+ val protocol: ImProtocol = ImProtocol.Jabber,
+) : Parcelable
+
+@Immutable
+@Parcelize
+internal data class WebsiteFieldState(
+ val id: String = UUID.randomUUID().toString(),
+ val url: String = "",
+ val type: WebsiteType = WebsiteType.Homepage,
+) : Parcelable
+
+@Immutable
+@Parcelize
+internal data class GroupFieldState(val groupId: Long, val title: String) : Parcelable
+
+@Immutable
+@Parcelize
+internal data class GroupInfo(val groupId: Long, val title: String) : Parcelable
diff --git a/src/com/android/contacts/ui/contactcreation/model/NameState.kt b/src/com/android/contacts/ui/contactcreation/model/NameState.kt
new file mode 100644
index 000000000..08ca93965
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/model/NameState.kt
@@ -0,0 +1,19 @@
+package com.android.contacts.ui.contactcreation.model
+
+import android.os.Parcelable
+import androidx.compose.runtime.Immutable
+import kotlinx.parcelize.Parcelize
+
+@Immutable
+@Parcelize
+internal data class NameState(
+ val prefix: String = "",
+ val first: String = "",
+ val middle: String = "",
+ val last: String = "",
+ val suffix: String = "",
+) : Parcelable {
+ fun hasData(): Boolean =
+ prefix.isNotBlank() || first.isNotBlank() ||
+ middle.isNotBlank() || last.isNotBlank() || suffix.isNotBlank()
+}
diff --git a/src/com/android/contacts/ui/contactcreation/preview/ContactCreationPreviews.kt b/src/com/android/contacts/ui/contactcreation/preview/ContactCreationPreviews.kt
new file mode 100644
index 000000000..c66c6c454
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/preview/ContactCreationPreviews.kt
@@ -0,0 +1,264 @@
+package com.android.contacts.ui.contactcreation.preview
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.contacts.ui.contactcreation.ContactCreationEditorScreen
+import com.android.contacts.ui.contactcreation.component.AddMoreInfoSection
+import com.android.contacts.ui.contactcreation.component.AddressSectionContent
+import com.android.contacts.ui.contactcreation.component.EmailSectionContent
+import com.android.contacts.ui.contactcreation.component.GroupCheckboxRow
+import com.android.contacts.ui.contactcreation.component.GroupSectionContent
+import com.android.contacts.ui.contactcreation.component.NameSectionContent
+import com.android.contacts.ui.contactcreation.component.OrganizationSectionContent
+import com.android.contacts.ui.contactcreation.component.PhoneFieldRow
+import com.android.contacts.ui.contactcreation.component.PhoneSectionContent
+import com.android.contacts.ui.contactcreation.component.PhotoAvatar
+import com.android.contacts.ui.contactcreation.component.PhotoSectionContent
+import com.android.contacts.ui.core.AppTheme
+
+// region Full Screen Previews
+
+@Preview(showBackground = true, showSystemUi = true)
+@Composable
+private fun ContactCreationEditorScreenPreview() {
+ AppTheme {
+ ContactCreationEditorScreen(
+ uiState = PreviewData.fullUiState,
+ accounts = emptyList(),
+ onAction = {},
+ )
+ }
+}
+
+@Preview(showBackground = true, showSystemUi = true)
+@Composable
+private fun ContactCreationEditorScreenEmptyPreview() {
+ AppTheme {
+ ContactCreationEditorScreen(
+ uiState = PreviewData.emptyUiState,
+ accounts = emptyList(),
+ onAction = {},
+ )
+ }
+}
+
+@Preview(
+ showBackground = true,
+ showSystemUi = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+)
+@Composable
+private fun ContactCreationEditorScreenDarkPreview() {
+ AppTheme {
+ ContactCreationEditorScreen(
+ uiState = PreviewData.fullUiState,
+ accounts = emptyList(),
+ onAction = {},
+ )
+ }
+}
+
+// endregion
+
+// region PhotoSection
+
+@Preview(showBackground = true)
+@Composable
+private fun PhotoSectionNoPhotoPreview() {
+ AppTheme {
+ PhotoSectionContent(photoUri = null, onAction = {})
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PhotoAvatarNoPhotoPreview() {
+ AppTheme {
+ PhotoAvatar(
+ photoUri = null,
+ onAction = {},
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ )
+ }
+}
+
+// endregion
+
+// region NameSection
+
+@Preview(showBackground = true)
+@Composable
+private fun NameSectionPreview() {
+ AppTheme {
+ NameSectionContent(nameState = PreviewData.nameState, onAction = {})
+ }
+}
+
+// endregion
+
+// region PhoneSection
+
+@Preview(showBackground = true)
+@Composable
+private fun PhoneSectionPreview() {
+ AppTheme {
+ PhoneSectionContent(phones = PreviewData.phones, onAction = {})
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PhoneSectionSinglePreview() {
+ AppTheme {
+ PhoneSectionContent(phones = PreviewData.singlePhone, onAction = {})
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PhoneFieldRowPreview() {
+ AppTheme {
+ PhoneFieldRow(
+ phone = PreviewData.phones[0],
+ index = 0,
+ onAction = {},
+ )
+ }
+}
+
+// endregion
+
+// region EmailSection
+
+@Preview(showBackground = true)
+@Composable
+private fun EmailSectionPreview() {
+ AppTheme {
+ EmailSectionContent(emails = PreviewData.emails, onAction = {})
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EmailSectionSinglePreview() {
+ AppTheme {
+ EmailSectionContent(emails = PreviewData.singleEmail, onAction = {})
+ }
+}
+
+// endregion
+
+// region AddressSection
+
+@Preview(showBackground = true)
+@Composable
+private fun AddressSectionPreview() {
+ AppTheme {
+ AddressSectionContent(addresses = PreviewData.addresses, onAction = {})
+ }
+}
+
+// endregion
+
+// region OrganizationSection
+
+@Preview(showBackground = true)
+@Composable
+private fun OrganizationFieldsPreview() {
+ AppTheme {
+ OrganizationSectionContent(organization = PreviewData.organization, onAction = {})
+ }
+}
+
+// endregion
+
+// region AddMoreInfoSection
+
+@Preview(showBackground = true)
+@Composable
+private fun AddMoreInfoSectionAllChipsPreview() {
+ AppTheme {
+ AddMoreInfoSection(
+ showAddressChip = true,
+ showOrgChip = true,
+ showNoteChip = true,
+ showGroupsChip = true,
+ showOtherChip = true,
+ onAddAddress = {},
+ onShowOrganization = {},
+ onShowNote = {},
+ onShowGroups = {},
+ onShowOtherSheet = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AddMoreInfoSectionPartialChipsPreview() {
+ AppTheme {
+ AddMoreInfoSection(
+ showAddressChip = false,
+ showOrgChip = true,
+ showNoteChip = false,
+ showGroupsChip = true,
+ showOtherChip = true,
+ onAddAddress = {},
+ onShowOrganization = {},
+ onShowNote = {},
+ onShowGroups = {},
+ onShowOtherSheet = {},
+ )
+ }
+}
+
+// endregion
+
+// region GroupSection
+
+@Preview(showBackground = true)
+@Composable
+private fun GroupSectionPreview() {
+ AppTheme {
+ GroupSectionContent(
+ availableGroups = PreviewData.availableGroups,
+ selectedGroups = PreviewData.selectedGroups,
+ onAction = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GroupCheckboxRowSelectedPreview() {
+ AppTheme {
+ GroupCheckboxRow(
+ group = PreviewData.availableGroups[0],
+ isSelected = true,
+ index = 0,
+ onAction = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GroupCheckboxRowUnselectedPreview() {
+ AppTheme {
+ GroupCheckboxRow(
+ group = PreviewData.availableGroups[1],
+ isSelected = false,
+ index = 1,
+ onAction = {},
+ )
+ }
+}
+
+// endregion
+
+// endregion
diff --git a/src/com/android/contacts/ui/contactcreation/preview/PreviewData.kt b/src/com/android/contacts/ui/contactcreation/preview/PreviewData.kt
new file mode 100644
index 000000000..5114e674d
--- /dev/null
+++ b/src/com/android/contacts/ui/contactcreation/preview/PreviewData.kt
@@ -0,0 +1,116 @@
+package com.android.contacts.ui.contactcreation.preview
+
+import com.android.contacts.ui.contactcreation.component.AddressType
+import com.android.contacts.ui.contactcreation.component.EmailType
+import com.android.contacts.ui.contactcreation.component.EventType
+import com.android.contacts.ui.contactcreation.component.ImProtocol
+import com.android.contacts.ui.contactcreation.component.PhoneType
+import com.android.contacts.ui.contactcreation.component.RelationType
+import com.android.contacts.ui.contactcreation.component.WebsiteType
+import com.android.contacts.ui.contactcreation.model.AddressFieldState
+import com.android.contacts.ui.contactcreation.model.ContactCreationUiState
+import com.android.contacts.ui.contactcreation.model.EmailFieldState
+import com.android.contacts.ui.contactcreation.model.EventFieldState
+import com.android.contacts.ui.contactcreation.model.GroupFieldState
+import com.android.contacts.ui.contactcreation.model.GroupInfo
+import com.android.contacts.ui.contactcreation.model.ImFieldState
+import com.android.contacts.ui.contactcreation.model.NameState
+import com.android.contacts.ui.contactcreation.model.OrganizationFieldState
+import com.android.contacts.ui.contactcreation.model.PhoneFieldState
+import com.android.contacts.ui.contactcreation.model.RelationFieldState
+import com.android.contacts.ui.contactcreation.model.WebsiteFieldState
+
+internal object PreviewData {
+
+ val nameState = NameState(
+ first = "Jane",
+ last = "Doe",
+ )
+
+ val phones = listOf(
+ PhoneFieldState(id = "phone-1", number = "555-1234", type = PhoneType.Mobile),
+ PhoneFieldState(id = "phone-2", number = "555-5678", type = PhoneType.Work),
+ )
+
+ val singlePhone = listOf(
+ PhoneFieldState(id = "phone-1", number = "555-1234", type = PhoneType.Mobile),
+ )
+
+ val emails = listOf(
+ EmailFieldState(id = "email-1", address = "jane@example.com", type = EmailType.Home),
+ EmailFieldState(id = "email-2", address = "jane@work.com", type = EmailType.Work),
+ )
+
+ val singleEmail = listOf(
+ EmailFieldState(id = "email-1", address = "jane@example.com", type = EmailType.Home),
+ )
+
+ val addresses = listOf(
+ AddressFieldState(
+ id = "addr-1",
+ street = "123 Main St",
+ city = "Springfield",
+ region = "IL",
+ postcode = "62701",
+ country = "US",
+ type = AddressType.Home,
+ ),
+ )
+
+ val organization = OrganizationFieldState(
+ company = "Acme Corp",
+ title = "Software Engineer",
+ )
+
+ val events = listOf(
+ EventFieldState(id = "event-1", startDate = "1990-01-15", type = EventType.Birthday),
+ EventFieldState(id = "event-2", startDate = "2020-06-20", type = EventType.Anniversary),
+ )
+
+ val relations = listOf(
+ RelationFieldState(id = "rel-1", name = "John Doe", type = RelationType.Spouse),
+ )
+
+ val imAccounts = listOf(
+ ImFieldState(id = "im-1", data = "jane_doe", protocol = ImProtocol.Jabber),
+ )
+
+ val websites = listOf(
+ WebsiteFieldState(id = "web-1", url = "https://janedoe.dev", type = WebsiteType.Homepage),
+ )
+
+ val availableGroups = listOf(
+ GroupInfo(groupId = 1L, title = "Friends"),
+ GroupInfo(groupId = 2L, title = "Family"),
+ GroupInfo(groupId = 3L, title = "Coworkers"),
+ )
+
+ val selectedGroups = listOf(
+ GroupFieldState(groupId = 1L, title = "Friends"),
+ )
+
+ val fullUiState = ContactCreationUiState(
+ nameState = nameState,
+ phoneNumbers = phones,
+ emails = emails,
+ addresses = addresses,
+ organization = organization,
+ events = events,
+ relations = relations,
+ imAccounts = imAccounts,
+ websites = websites,
+ note = "Met at the conference",
+ nickname = "JD",
+ sipAddress = "jane@sip.example.com",
+ groups = selectedGroups,
+ availableGroups = availableGroups,
+ accountName = "jane@gmail.com",
+ showOrganization = true,
+ showNote = true,
+ showNickname = true,
+ showSipAddress = true,
+ showSipField = true,
+ )
+
+ val emptyUiState = ContactCreationUiState()
+}
diff --git a/src/com/android/contacts/ui/core/Theme.kt b/src/com/android/contacts/ui/core/Theme.kt
index 2165a9a30..854a0cd00 100644
--- a/src/com/android/contacts/ui/core/Theme.kt
+++ b/src/com/android/contacts/ui/core/Theme.kt
@@ -1,12 +1,18 @@
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
+
package com.android.contacts.ui.core
+import android.provider.Settings
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MotionScheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -31,6 +37,21 @@ fun AppTheme(
MaterialTheme(
colorScheme = colorScheme,
shapes = AppShapes,
+ motionScheme = MotionScheme.expressive(),
content = content,
)
}
+
+/** True when the user has enabled reduce-motion / disabled animations. */
+@Composable
+internal fun isReduceMotionEnabled(): Boolean {
+ val context = LocalContext.current
+ return remember {
+ val scale = Settings.Global.getFloat(
+ context.contentResolver,
+ Settings.Global.ANIMATOR_DURATION_SCALE,
+ 1f,
+ )
+ scale == 0f
+ }
+}