diff --git a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/CollectionPlan.kt b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/CollectionPlan.kt new file mode 100644 index 0000000000..a59ac463de --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/CollectionPlan.kt @@ -0,0 +1,276 @@ +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kickstarter.R +import com.kickstarter.ui.compose.designsystem.KSTheme +import com.kickstarter.ui.compose.designsystem.KSTheme.colors +import com.kickstarter.ui.compose.designsystem.KSTheme.dimensions +import com.kickstarter.ui.compose.designsystem.KSTheme.typography + +enum class CollectionPlanTestTags { + OPTION_PLEDGE_IN_FULL, + OPTION_PLEDGE_OVER_TIME, + DESCRIPTION_TEXT, + BADGE_TEXT, + EXPANDED_DESCRIPTION_TEXT, + TERMS_OF_USE_TEXT, + CHARGE_ITEM, +} + +enum class CollectionOptions { + PLEDGE_IN_FULL, + PLEDGE_OVER_TIME, +} + +@Preview( + name = "Light Eligible - Pledge in Full Selected", + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Preview( + name = "Dark Eligible - Pledge in Full Selected", + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +fun PreviewPledgeInFullSelected() { + KSTheme { + CollectionPlan( + isEligible = true, + initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name + ) + } +} + +@Preview( + name = "Light Eligible - Pledge Over Time Selected", + uiMode = Configuration.UI_MODE_NIGHT_NO +) +@Preview( + name = "Dark Eligible - Pledge Over Time Selected", + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +fun PreviewPledgeOverTimeSelected() { + KSTheme { + CollectionPlan( + isEligible = true, + initialSelectedOption = CollectionOptions.PLEDGE_OVER_TIME.name + ) + } +} + +@Preview(name = "Light Not Eligible", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Not Eligible", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewNotEligibleComponent() { + KSTheme { + CollectionPlan( + isEligible = false, + initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name + ) + } +} + +@Composable +fun CollectionPlan( + isEligible: Boolean, + initialSelectedOption: String = CollectionOptions.PLEDGE_IN_FULL.name +) { + var selectedOption by remember { mutableStateOf(initialSelectedOption) } + + Column(modifier = Modifier.fillMaxWidth()) { + PledgeOption( + optionText = stringResource(id = R.string.fpo_pledge_in_full), + selected = selectedOption == CollectionOptions.PLEDGE_IN_FULL.name, + onSelect = { selectedOption = CollectionOptions.PLEDGE_IN_FULL.name }, + modifier = Modifier.testTag(CollectionPlanTestTags.OPTION_PLEDGE_IN_FULL.name) + ) + Spacer(Modifier.height(dimensions.paddingSmall)) + PledgeOption( + modifier = Modifier.testTag(CollectionPlanTestTags.OPTION_PLEDGE_OVER_TIME.name), + optionText = stringResource(id = R.string.fpo_pledge_over_time), + selected = selectedOption == CollectionOptions.PLEDGE_OVER_TIME.name, + description = if (isEligible) stringResource(id = R.string.fpo_you_will_be_charged_for_your_pledge_over_four_payments_at_no_extra_cost) else null, + onSelect = { + if (isEligible) selectedOption = CollectionOptions.PLEDGE_OVER_TIME.name + }, + isExpanded = selectedOption == CollectionOptions.PLEDGE_OVER_TIME.name && isEligible, + isSelectable = isEligible, + showBadge = !isEligible, + ) + } +} + +@Composable +fun PledgeOption( + modifier: Modifier = Modifier, + optionText: String, + selected: Boolean, + description: String? = null, + onSelect: () -> Unit, + isExpanded: Boolean = false, + isSelectable: Boolean = true, + showBadge: Boolean = false, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(dimensions.radiusSmall)) + .background(colors.kds_white) + .clickable(enabled = isSelectable, onClick = onSelect) + .padding(end = dimensions.paddingSmall, bottom = dimensions.paddingSmall) + .semantics { this.selected = selected } + .then( + if (!isSelectable) Modifier.padding( + vertical = dimensions.paddingMediumSmall, + horizontal = dimensions.paddingMediumSmall + ) else Modifier + ) + ) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(start = dimensions.paddingSmall) + ) { + Column { + RadioButton( + modifier = if (!isSelectable) Modifier.padding(end = dimensions.paddingMediumSmall) else Modifier, + selected = selected, + onClick = onSelect.takeIf { isSelectable }, + colors = RadioButtonDefaults.colors( + selectedColor = colors.kds_create_700, + unselectedColor = colors.kds_support_300 + ) + ) + } + Column { + Text( + modifier = Modifier.padding( + top = if (isSelectable) dimensions.paddingMedium else dimensions.dialogButtonSpacing, + bottom = dimensions.paddingSmall + ), + text = optionText, + style = typography.subheadlineMedium, + color = if (isSelectable) colors.kds_black else colors.textDisabled + ) + if (showBadge) { + Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) + PledgeBadge() + } else if (description != null) { + Text( + modifier = Modifier + .padding(bottom = dimensions.paddingSmall) + .testTag(CollectionPlanTestTags.DESCRIPTION_TEXT.name), + text = description, + style = typography.caption2, + color = colors.textDisabled + ) + } + if (isExpanded) { + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + Text( + modifier = Modifier.testTag(CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name), + text = stringResource(id = R.string.fpo_the_first_charge_will_be_24_hours_after_the_project_ends_successfully), + style = typography.caption2, + color = colors.textDisabled + ) + Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) + Text( + modifier = Modifier.testTag(CollectionPlanTestTags.TERMS_OF_USE_TEXT.name), + text = stringResource(id = R.string.fpo_see_our_terms_of_use), + style = typography.caption2, + color = colors.textAccentGreen + ) + ChargeSchedule() + } + } + } + } +} + +@Composable +fun PledgeBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background( + color = colors.borderSubtle, + shape = RoundedCornerShape(dimensions.radiusSmall) + ) + .padding( + start = dimensions.paddingSmall, + end = dimensions.paddingSmall, + top = dimensions.paddingXSmall, + bottom = dimensions.paddingXSmall, + ) + ) { + Text( + modifier = Modifier.testTag(CollectionPlanTestTags.BADGE_TEXT.name), + text = stringResource( + id = R.string.fpo_available_for_pledges_over_150 + ), + style = typography.body2Medium, + color = colors.textDisabled + ) + } +} + +@Composable +fun ChargeSchedule() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + ChargeItem("Charge 1", "Aug 11, 2024", "$250") + ChargeItem("Charge 2", "Aug 15, 2024", "$250") + ChargeItem("Charge 3", "Aug 29, 2024", "$250") + ChargeItem("Charge 4", "Sep 12, 2024", "$250") + } +} + +@Composable +fun ChargeItem(title: String, date: String, amount: String) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.padding(bottom = dimensions.paddingMediumLarge)) { + Text( + modifier = Modifier.testTag(CollectionPlanTestTags.CHARGE_ITEM.name), + text = title, style = typography.body2Medium + ) + + Row(modifier = Modifier.padding(top = dimensions.paddingXSmall)) { + Text(text = date, color = colors.textSecondary, style = typography.footnote) + Spacer(modifier = Modifier.width(dimensions.paddingXLarge)) + Text(text = amount, color = colors.textSecondary, style = typography.footnote) + } + } + } +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2697f8fe2f..c4b564426d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -9,6 +9,10 @@ @color/kds_white @color/kds_support_100 + + #F0F0F0 + #656969 + #A12027 @android:color/black @@ -79,4 +83,8 @@ #FBFBFA + + + #B3B3B3 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ac58d1b16..74acb636df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,4 +89,13 @@ This project was successfully funded on %{deadline}, but you can still pledge for available rewards. + + Pledge in Full + Pledge Over Time + Collection Plan + Payment + Available for pledges over $150 + See our Terms of Use + You will be charged for your pledge over four payments, at no extra cost. + The first charge will be 24 hours after the project ends successfully, then every 2 weeks until fully paid. When this option is selected no further edits can be made to your pledge. diff --git a/app/src/test/java/com/kickstarter/ui/activities/compose/CollectionPlanTest.kt b/app/src/test/java/com/kickstarter/ui/activities/compose/CollectionPlanTest.kt new file mode 100644 index 0000000000..c14ec1eba2 --- /dev/null +++ b/app/src/test/java/com/kickstarter/ui/activities/compose/CollectionPlanTest.kt @@ -0,0 +1,175 @@ +package com.kickstarter.ui.activities.compose + +import CollectionOptions +import CollectionPlan +import CollectionPlanTestTags +import android.content.Context +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.isNotDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.platform.app.InstrumentationRegistry +import com.kickstarter.KSRobolectricTestCase +import com.kickstarter.R +import com.kickstarter.ui.compose.designsystem.KSTheme +import org.junit.Before +import org.junit.Test + +class CollectionPlanTest : KSRobolectricTestCase() { + + private lateinit var context: Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + private val pledgeInFullOption + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.OPTION_PLEDGE_IN_FULL.name) + private val pledgeOverTimeOption + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.OPTION_PLEDGE_OVER_TIME.name) + private val descriptionText + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.DESCRIPTION_TEXT.name) + private val badgeText + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.BADGE_TEXT.name) + private val expandedText + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name) + private val termsText + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.TERMS_OF_USE_TEXT.name) + + @Test + fun testPledgeInFullOptionSelected() { + val pledgeInFullText = context.getString(R.string.fpo_pledge_in_full) + val pledgeOverTimeText = context.getString(R.string.fpo_pledge_over_time) + val descriptionTextValue = + context.getString(R.string.fpo_you_will_be_charged_for_your_pledge_over_four_payments_at_no_extra_cost) + + composeTestRule.setContent { + KSTheme { + CollectionPlan(isEligible = true, initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name) + } + } + + composeTestRule.waitForIdle() + + // Assert "Pledge in Full" option is displayed with correct text and is selected + pledgeInFullOption.assertIsDisplayed().assert(hasText(pledgeInFullText)).assertIsSelected() + + // Assert "Pledge Over Time" option is not displayed + // Assert "Pledge Over Time" option is displayed with correct text and is not selected + pledgeOverTimeOption.assertIsDisplayed().assert(hasText(pledgeOverTimeText)) + .assertIsNotSelected() + + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.DESCRIPTION_TEXT.name, + useUnmergedTree = true + ) + .assertIsDisplayed() + .assert(hasText(descriptionTextValue)) + + // Assert that other elements are not displayed + badgeText.assertIsNotDisplayed() + expandedText.assertIsNotDisplayed() + termsText.assertIsNotDisplayed() + } + + @Test + fun testPledgeOverTimeOptionSelected() { + val pledgeInFullText = context.getString(R.string.fpo_pledge_in_full) + val pledgeOverTimeText = context.getString(R.string.fpo_pledge_over_time) + val descriptionTextValue = + context.getString(R.string.fpo_you_will_be_charged_for_your_pledge_over_four_payments_at_no_extra_cost) + val extendedTextValue = + context.getString(R.string.fpo_the_first_charge_will_be_24_hours_after_the_project_ends_successfully) + val termsOfUseTextValue = context.getString(R.string.fpo_see_our_terms_of_use) + composeTestRule.setContent { + KSTheme { + CollectionPlan(isEligible = true, initialSelectedOption = CollectionOptions.PLEDGE_OVER_TIME.name) + } + } + + composeTestRule.waitForIdle() + + // Assert "Pledge in Full" option is displayed with correct text and is not selected + pledgeInFullOption.assertIsDisplayed().assert(hasText(pledgeInFullText)) + .assertIsNotSelected() + + // Assert "Pledge Over Time" option is displayed with correct text and is selected + pledgeOverTimeOption.assertIsDisplayed().assert(hasText(pledgeOverTimeText)) + .assertIsSelected() + + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.DESCRIPTION_TEXT.name, + useUnmergedTree = true + ) + .assertIsDisplayed() + .assert(hasText(descriptionTextValue)) + + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name, + useUnmergedTree = true + ) + .assertIsDisplayed() + .assert(hasText(extendedTextValue)) + + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.TERMS_OF_USE_TEXT.name, + useUnmergedTree = true + ) + .assertIsDisplayed() + .assert(hasText(termsOfUseTextValue)) + + // Not eligible badge should not be displayed + badgeText.assertIsNotDisplayed() + } + + @Test + fun testPledgeOverTimeOptionIneligible() { + val pledgeInFullText = context.getString(R.string.fpo_pledge_in_full) + val pledgeOverTimeText = context.getString(R.string.fpo_pledge_over_time) + composeTestRule.setContent { + KSTheme { + CollectionPlan(isEligible = false, initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name) + } + } + + composeTestRule.waitForIdle() + + // Assert "Pledge in Full" option is displayed with correct text and is selected + pledgeInFullOption.assertIsDisplayed().assert(hasText(pledgeInFullText)) + .assertIsSelected() + + pledgeOverTimeOption.assertIsDisplayed().assert(hasText(pledgeOverTimeText)) + .assertIsNotSelected() + + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.DESCRIPTION_TEXT.name, + useUnmergedTree = true + ) + .assertIsNotDisplayed() + + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name, + useUnmergedTree = true + ) + .isNotDisplayed() + + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.TERMS_OF_USE_TEXT.name, + useUnmergedTree = true + ) + .isNotDisplayed() + + // Assert that other elements are not displayed + composeTestRule.onNodeWithTag( + CollectionPlanTestTags.BADGE_TEXT.name, + useUnmergedTree = true + ) + .isDisplayed() + } +} diff --git a/app/src/test/java/com/kickstarter/ui/activities/compose/RisksScreenTest.kt b/app/src/test/java/com/kickstarter/ui/activities/compose/RisksScreenTest.kt index a43e3544e1..51476fde16 100644 --- a/app/src/test/java/com/kickstarter/ui/activities/compose/RisksScreenTest.kt +++ b/app/src/test/java/com/kickstarter/ui/activities/compose/RisksScreenTest.kt @@ -19,8 +19,10 @@ class RisksScreenTest : KSRobolectricTestCase() { val context = InstrumentationRegistry.getInstrumentation().targetContext private val pageTitle = composeTestRule.onNodeWithTag(RisksScreenTestTag.PAGE_TITLE.name) - private val riskDescriptionTextView = composeTestRule.onNodeWithTag(RisksScreenTestTag.RISK_DESCRIPTION.name) - private val clickableTextView = composeTestRule.onNodeWithTag(RisksScreenTestTag.CLICKABLE_TEXT.name) + private val riskDescriptionTextView = + composeTestRule.onNodeWithTag(RisksScreenTestTag.RISK_DESCRIPTION.name) + private val clickableTextView = + composeTestRule.onNodeWithTag(RisksScreenTestTag.CLICKABLE_TEXT.name) @Test fun `test screen init`() {