From 69961266d0633953d3582f0254232b03065d8de3 Mon Sep 17 00:00:00 2001 From: jlplks Date: Wed, 27 Nov 2024 17:54:45 -0700 Subject: [PATCH] MBL-1858: Payment Schedule component (#2178) * PaymentSchedule component created and tests created * Payment schedule * added todo * fix kslint * Created and implemented PaymentIncrement data model --- .../kickstarter/models/PaymentIncrement.kt | 45 ++++ .../views/compose/checkout/PaymentSchedule.kt | 250 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 + .../activities/compose/PaymentScheduleTest.kt | 131 +++++++++ 4 files changed, 430 insertions(+) create mode 100644 app/src/main/java/com/kickstarter/models/PaymentIncrement.kt create mode 100644 app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt create mode 100644 app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt diff --git a/app/src/main/java/com/kickstarter/models/PaymentIncrement.kt b/app/src/main/java/com/kickstarter/models/PaymentIncrement.kt new file mode 100644 index 0000000000..d2b7454b72 --- /dev/null +++ b/app/src/main/java/com/kickstarter/models/PaymentIncrement.kt @@ -0,0 +1,45 @@ +package com.kickstarter.models + +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +data class PaymentIncrement( + val id: Long, + val amount: Int, + val state: State, + val paymentIncrementalType: String, + val paymentIncrementalId: Long, + val date: Instant +) { + val formattedDate: String + get() { + val zonedDateTime = ZonedDateTime.ofInstant(date, ZoneOffset.UTC) + val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy") + return zonedDateTime.format(formatter) + } + enum class State { + UNATTEMPTED, + COLLECTED + } + + fun stateAsString(): String { + return state.name.lowercase() + } + + companion object { + fun create( + id: Long, + amount: Int, + state: State, + paymentIncrementalType: String, + paymentIncrementalId: Long, + date: Instant + ): PaymentIncrement { + return PaymentIncrement( + id, amount, state, paymentIncrementalType, paymentIncrementalId, date + ) + } + } +} diff --git a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt new file mode 100644 index 0000000000..978ae0c787 --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt @@ -0,0 +1,250 @@ +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.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kickstarter.R +import com.kickstarter.models.PaymentIncrement +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 +import java.time.Instant +import java.util.Locale + +enum class PaymentScheduleTestTags { + PAYMENT_SCHEDULE_TITLE, + EXPAND_ICON, + DATE_TEXT, + AMOUNT_TEXT, + BADGE_TEXT, + TERMS_OF_USE_TEXT +} + +val samplePaymentIncrements = listOf( + PaymentIncrement( + id = 1234, + amount = 3400, + state = PaymentIncrement.State.UNATTEMPTED, + paymentIncrementalId = 1, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-14T18:12:00Z") // Mon, 14 Oct 2024 18:12 UTC + ), + PaymentIncrement( + id = 1235, + amount = 2500, + state = PaymentIncrement.State.COLLECTED, + paymentIncrementalId = 2, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-15T14:00:00Z") // Tue, 15 Oct 2024 14:00 UTC + ), + PaymentIncrement( + id = 1236, + amount = 4500, + state = PaymentIncrement.State.UNATTEMPTED, + paymentIncrementalId = 3, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-16T10:00:00Z") // Wed, 16 Oct 2024 10:00 UTC + ), + PaymentIncrement( + id = 1237, + amount = 5200, + state = PaymentIncrement.State.COLLECTED, + paymentIncrementalId = 4, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-17T16:30:00Z") // Thu, 17 Oct 2024 16:30 UTC + ) +) + +@Preview( + name = "Dark Collapsed State", uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview( + name = "Light Collapsed State", uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Composable +fun PreviewCollapsedPaymentScheduleWhite() { + KSTheme { + PaymentSchedule( + isExpanded = false, + onExpandChange = {} + + ) + } +} + +// Expanded State Preview +@Preview(showBackground = true, name = "Expanded State") +@Composable +fun PreviewExpandedPaymentSchedule() { + KSTheme { + PaymentSchedule( + isExpanded = true, + onExpandChange = {}, + paymentIncrements = samplePaymentIncrements + ) + } +} + +@Preview +@Composable +fun InteractivePaymentSchedulePreview() { + var isExpanded by remember { mutableStateOf(false) } + KSTheme { + PaymentSchedule( + isExpanded = isExpanded, + onExpandChange = { isExpanded = it }, + paymentIncrements = samplePaymentIncrements + + ) + } +} + +@Composable +fun PaymentSchedule( + isExpanded: Boolean = false, + onExpandChange: (Boolean) -> Unit = {}, + paymentIncrements: List = listOf() +) { + Card( + elevation = 0.dp, + modifier = Modifier + .fillMaxWidth() + .padding(dimensions.paddingMedium) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dimensions.paddingMedium) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.testTag(PaymentScheduleTestTags.PAYMENT_SCHEDULE_TITLE.name), + text = stringResource(id = R.string.fpo_payment_schedule), + style = typography.body2Medium, + ) + Icon( + modifier = Modifier + .testTag(PaymentScheduleTestTags.EXPAND_ICON.name) + .clickable { onExpandChange(!isExpanded) }, + painter = + if (isExpanded) painterResource(id = R.drawable.ic_arrow_up) else painterResource( + id = R.drawable.ic_arrow_down + ), + contentDescription = "Expand", + tint = colors.textSecondary, + ) + } + + if (isExpanded) { + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + paymentIncrements.forEach { paymentIncrement -> + PaymentRow(paymentIncrement) + } + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + Text( + modifier = Modifier.testTag(PaymentScheduleTestTags.TERMS_OF_USE_TEXT.name), + text = stringResource(id = R.string.fpo_terms_of_use), + style = typography.subheadline, + color = colors.textAccentGreen + ) + } + } + } +} + +@Composable +fun PaymentRow(paymentIncrement: PaymentIncrement) { + val formattedAmount = String.format(Locale.US, "%.2f", paymentIncrement.amount / 100.0) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensions.paddingSmall), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(dimensions.paddingXSmall) + ) { + Text( + modifier = Modifier.testTag(PaymentScheduleTestTags.DATE_TEXT.name), + text = paymentIncrement.formattedDate, + style = typography.body2Medium, + ) + StatusBadge(paymentIncrement.state) + } + Text( + modifier = Modifier.testTag(PaymentScheduleTestTags.AMOUNT_TEXT.name), + text = "USD$ $formattedAmount", + style = typography.title3 + ) + } +} + +@Composable +fun StatusBadge(state: PaymentIncrement.State) { + when (state) { + PaymentIncrement.State.UNATTEMPTED -> { + Box( + modifier = Modifier + .background( + color = colors.backgroundAccentOrangeSubtle, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + modifier = Modifier.testTag(PaymentScheduleTestTags.BADGE_TEXT.name), + text = stringResource(id = R.string.fpo_unattempted), + style = typography.caption1Medium, + color = colors.textSecondary + ) + } + } + + PaymentIncrement.State.COLLECTED -> { + Box( + modifier = Modifier + .background( + color = colors.textAccentGreen.copy(alpha = 0.2f), + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + modifier = Modifier.testTag(PaymentScheduleTestTags.BADGE_TEXT.name), + text = stringResource(id = R.string.fpo_collected), + style = typography.caption1Medium, + color = colors.textAccentGreen + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1159d53a9..ef69a9738b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -101,4 +101,8 @@ 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. Charged as 4 payments If the project reaches its funding goal, the first charge of $20 will be collected on March 15, 2024. + Terms of Use + Collected + Unattempted + Payment schedule diff --git a/app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt b/app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt new file mode 100644 index 0000000000..5f1aac733b --- /dev/null +++ b/app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt @@ -0,0 +1,131 @@ +package com.kickstarter.ui.activities.compose + +import PaymentSchedule +import android.content.Context +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.platform.app.InstrumentationRegistry +import com.kickstarter.KSRobolectricTestCase +import com.kickstarter.R +import com.kickstarter.models.PaymentIncrement +import com.kickstarter.models.PaymentIncrement.State +import com.kickstarter.ui.compose.designsystem.KSTheme +import org.junit.Before +import org.junit.Test +import java.time.Instant + +class PaymentScheduleTest : KSRobolectricTestCase() { + + private lateinit var context: Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + private val title + get() = composeTestRule.onNodeWithTag(PaymentScheduleTestTags.PAYMENT_SCHEDULE_TITLE.name) + private val expandIcon + get() = composeTestRule.onNodeWithTag( + PaymentScheduleTestTags.EXPAND_ICON.name, + ) + private val dateText + get() = composeTestRule.onAllNodesWithTag(PaymentScheduleTestTags.DATE_TEXT.name) + private val amountText + get() = composeTestRule.onAllNodesWithTag(PaymentScheduleTestTags.AMOUNT_TEXT.name) + private val badgeText + get() = composeTestRule.onAllNodesWithTag(PaymentScheduleTestTags.BADGE_TEXT.name) + private val termsOfUseText + get() = composeTestRule.onNodeWithTag(PaymentScheduleTestTags.TERMS_OF_USE_TEXT.name) + + private val samplePaymentIncrements = listOf( + PaymentIncrement( + id = 1234, + amount = 3400, + state = State.UNATTEMPTED, + paymentIncrementalId = 1, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-14T18:12:00Z") // Mon, 14 Oct 2024 18:12 UTC + ), + PaymentIncrement( + id = 1235, + amount = 2500, + state = State.COLLECTED, + paymentIncrementalId = 2, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-15T14:00:00Z") // Tue, 15 Oct 2024 14:00 UTC + ), + PaymentIncrement( + id = 1236, + amount = 4500, + state = State.UNATTEMPTED, + paymentIncrementalId = 3, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-16T10:00:00Z") // Wed, 16 Oct 2024 10:00 UTC + ), + PaymentIncrement( + id = 1237, + amount = 5200, + state = State.COLLECTED, + paymentIncrementalId = 4, + paymentIncrementalType = "pledge", + date = Instant.parse("2024-10-17T16:30:00Z") // Thu, 17 Oct 2024 16:30 UTC + ) + ) + + @Test + fun testCollapsedState() { + composeTestRule.setContent { + KSTheme { + PaymentSchedule( + isExpanded = false, + onExpandChange = {}, + paymentIncrements = samplePaymentIncrements + ) + } + } + + composeTestRule.waitForIdle() + + // Assert title and expand icon are displayed + title.assertIsDisplayed().assert(hasText(context.getString(R.string.fpo_payment_schedule))) + expandIcon.assertIsDisplayed() + + // Assert that payment details and terms of use are not displayed + dateText.assertCountEquals(0) + amountText.assertCountEquals(0) + termsOfUseText.assertIsNotDisplayed() + } + + @Test + fun testExpandedState() { + composeTestRule.setContent { + KSTheme { + PaymentSchedule( + isExpanded = true, + onExpandChange = {}, + paymentIncrements = samplePaymentIncrements + ) + } + } + + composeTestRule.waitForIdle() + + // Assert title and expand icon are displayed + title.assertIsDisplayed().assert(hasText(context.getString(R.string.fpo_payment_schedule))) + expandIcon.assertIsDisplayed() + + // Assert that payment details are displayed + dateText.assertCountEquals(4) + amountText.assertCountEquals(4) + badgeText.assertCountEquals(4) + + termsOfUseText + .assertIsDisplayed().assert(hasText(context.getString(R.string.fpo_terms_of_use))) + } +}