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 + } +}