diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POStroke.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POStroke.kt new file mode 100644 index 000000000..021388a41 --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POStroke.kt @@ -0,0 +1,29 @@ +package com.processout.sdk.ui.core.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi +import com.processout.sdk.ui.core.style.POStrokeStyle + +/** @suppress */ +@ProcessOutInternalApi +object POStroke { + + @Immutable + data class Style( + val width: Dp, + val color: Color, + val dashInterval: Dp? = null + ) + + @Composable + fun custom(style: POStrokeStyle) = Style( + width = style.widthDp.dp, + color = colorResource(id = style.colorResId), + dashInterval = style.dashIntervalDp?.dp + ) +} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POStepIcon.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POStepIcon.kt new file mode 100644 index 000000000..2723bea68 --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POStepIcon.kt @@ -0,0 +1,197 @@ +package com.processout.sdk.ui.core.component.stepper + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi +import com.processout.sdk.ui.core.component.POBorderStroke +import com.processout.sdk.ui.core.style.POStepperStyle + +/** @suppress */ +@ProcessOutInternalApi +@Composable +fun POStepIcon( + iconSize: Dp = POStepIcon.DefaultIconSize, + padding: Dp = POStepIcon.DefaultPadding, + style: POStepIcon.Style = POStepIcon.active +) { + val density = LocalDensity.current + val iconRadiusPx = with(density) { iconSize.toPx() / 2 } + val borderWidth = style.border?.width ?: 0.dp + val borderWidthPx = with(density) { borderWidth.toPx() } + val haloWidth = style.halo?.width ?: 0.dp + val haloWidthPx = with(density) { haloWidth.toPx() } + val checkmarkWidth = style.checkmark?.width ?: 0.dp + val checkmarkWidthPx = with(density) { checkmarkWidth.toPx() } + + val infiniteTransition = rememberInfiniteTransition() + val animatedHaloWidthPx by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = haloWidthPx, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 800, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ) + ) + Canvas( + modifier = Modifier + .padding(padding) + .requiredSize(size = iconSize) + ) { + val center = Offset(x = size.width / 2, y = size.height / 2) + // Halo + if (style.halo != null) { + drawCircle( + color = style.halo.color, + radius = iconRadiusPx + animatedHaloWidthPx, + center = center + ) + } + // Icon + drawCircle( + color = style.backgroundColor, + radius = iconRadiusPx, + center = center + ) + // Border + if (style.border != null) { + drawCircle( + color = style.border.color, + radius = iconRadiusPx - borderWidthPx / 2, + center = center, + style = Stroke(width = borderWidthPx) + ) + } + // Checkmark + if (style.checkmark != null) { + val checkmarkPath = Path().apply { + val scale = 1.3f + val start = Offset( + x = center.x - size.minDimension * 0.15f * scale, + y = center.y + ) + val mid = Offset( + x = center.x - size.minDimension * 0.05f * scale, + y = center.y + size.minDimension * 0.15f * scale + ) + val end = Offset( + x = center.x + size.minDimension * 0.2f * scale, + y = center.y - size.minDimension * 0.15f * scale + ) + moveTo(start.x, start.y) + lineTo(mid.x, mid.y) + lineTo(end.x, end.y) + } + drawPath( + path = checkmarkPath, + color = style.checkmark.color, + style = Stroke( + width = checkmarkWidthPx, + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + } + } +} + +/** @suppress */ +@ProcessOutInternalApi +object POStepIcon { + + data class Style( + val backgroundColor: Color, + val border: POBorderStroke?, + val halo: Halo?, + val checkmark: Checkmark? + ) + + data class Halo( + val width: Dp, + val color: Color + ) + + data class Checkmark( + val width: Dp, + val color: Color + ) + + internal val DefaultIconSize: Dp = 24.dp + internal val DefaultPadding: Dp = 6.dp + + private val DefaultBorderColor = Color(0xFFA3A3A3) + internal val DefaultCompletedColor = Color(0xFF4CA259) + + val pending: Style + @Composable get() = Style( + backgroundColor = Color.Transparent, + border = POBorderStroke( + width = 1.5.dp, + color = DefaultBorderColor + ), + halo = null, + checkmark = null + ) + + val active: Style + @Composable get() = Style( + backgroundColor = Color.White, + border = POBorderStroke( + width = 1.5.dp, + color = DefaultBorderColor + ), + halo = Halo( + width = 6.dp, + color = Color.Black.copy(alpha = 0.07f) + ), + checkmark = null + ) + + val completed: Style + @Composable get() = Style( + backgroundColor = DefaultCompletedColor, + border = null, + halo = null, + checkmark = Checkmark( + width = 2.dp, + color = Color.White + ) + ) + + @Composable + fun custom(style: POStepperStyle.IconStyle) = Style( + backgroundColor = colorResource(id = style.backgroundColorResId), + border = style.border?.let { + POBorderStroke( + width = it.widthDp.dp, + color = colorResource(id = it.colorResId) + ) + }, + halo = style.halo?.let { + Halo( + width = it.widthDp.dp, + color = colorResource(id = it.colorResId) + ) + }, + checkmark = style.checkmark?.let { + Checkmark( + width = it.widthDp.dp, + color = colorResource(id = it.colorResId) + ) + } + ) +} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POStepper.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POStepper.kt new file mode 100644 index 000000000..c2d74bc37 --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POStepper.kt @@ -0,0 +1,132 @@ +package com.processout.sdk.ui.core.component.stepper + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi +import com.processout.sdk.ui.core.component.POStroke +import com.processout.sdk.ui.core.component.POText +import com.processout.sdk.ui.core.component.stepper.POStepper.StepState.* +import com.processout.sdk.ui.core.style.POStepperStyle +import com.processout.sdk.ui.core.theme.ProcessOutTheme.colors +import com.processout.sdk.ui.core.theme.ProcessOutTheme.typography + +/** @suppress */ +@ProcessOutInternalApi +object POStepper { + + data class Step( + val title: String, + val description: String? = null + ) + + enum class StepState { + PENDING, + ACTIVE, + COMPLETED + } + + data class Style( + val pending: StepStyle, + val active: StepStyle, + val completed: StepStyle + ) + + data class StepStyle( + val title: POText.Style, + val description: POText.Style, + val icon: POStepIcon.Style, + val connector: POStroke.Style + ) + + val default: Style + @Composable get() = Style( + pending = StepStyle( + title = POText.Style( + color = colors.text.tertiary, + textStyle = typography.s15(FontWeight.Medium) + ), + description = POText.Style( + color = colors.text.tertiary, + textStyle = typography.s12(FontWeight.Medium) + ), + icon = POStepIcon.pending, + connector = POStroke.Style( + width = 2.dp, + color = Color(0xFFCECECE), + dashInterval = 3.dp + ) + ), + active = StepStyle( + title = POText.Style( + color = colors.text.primary, + textStyle = typography.s15(FontWeight.Medium) + ), + description = POText.Style( + color = colors.text.secondary, + textStyle = typography.s12(FontWeight.Medium) + ), + icon = POStepIcon.active, + connector = POStroke.Style( + width = 2.dp, + color = POStepIcon.DefaultCompletedColor, + dashInterval = 3.dp + ) + ), + completed = StepStyle( + title = POText.Style( + color = colors.text.positive, + textStyle = typography.s15(FontWeight.Medium) + ), + description = POText.Style( + color = colors.text.secondary, + textStyle = typography.s12(FontWeight.Medium) + ), + icon = POStepIcon.completed, + connector = POStroke.Style( + width = 2.dp, + color = POStepIcon.DefaultCompletedColor + ) + ) + ) + + @Composable + fun custom(style: POStepperStyle) = Style( + pending = style.pending.toStepStyle(), + active = style.active.toStepStyle(), + completed = style.completed.toStepStyle() + ) + + @Composable + private fun POStepperStyle.StepStyle.toStepStyle() = StepStyle( + title = POText.custom(style = title), + description = POText.custom(style = description), + icon = POStepIcon.custom(style = icon), + connector = POStroke.custom(style = connector) + ) + + internal fun stepStyle( + style: Style, + state: StepState + ): StepStyle = + when (state) { + PENDING -> style.pending + ACTIVE -> style.active + COMPLETED -> style.completed + } + + internal fun connectorStyle( + style: Style, + states: List, + currentStepIndex: Int + ): POStroke.Style { + val current = states.getOrNull(index = currentStepIndex) + val next = states.getOrNull(index = currentStepIndex + 1) + return when { + current == COMPLETED && next == COMPLETED -> style.completed.connector + current == COMPLETED && next == ACTIVE -> style.active.connector + else -> style.pending.connector + } + } +} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POVerticalStepper.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POVerticalStepper.kt new file mode 100644 index 000000000..72dda4da9 --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/stepper/POVerticalStepper.kt @@ -0,0 +1,143 @@ +package com.processout.sdk.ui.core.component.stepper + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi +import com.processout.sdk.ui.core.component.POExpandableText +import com.processout.sdk.ui.core.component.POText +import com.processout.sdk.ui.core.component.stepper.POStepper.StepState.* +import com.processout.sdk.ui.core.state.POImmutableList +import com.processout.sdk.ui.core.theme.ProcessOutTheme.spacing + +/** @suppress */ +@ProcessOutInternalApi +@Composable +fun POVerticalStepper( + steps: POImmutableList, + modifier: Modifier = Modifier, + style: POStepper.Style = POStepper.default, + activeStepIndex: Int = 0 +) { + Column(modifier = modifier) { + val states = List(steps.elements.size) { index -> + when { + index < activeStepIndex -> COMPLETED + index == activeStepIndex -> ACTIVE + else -> PENDING + } + } + steps.elements.forEachIndexed { index, step -> + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + val iconSize = POStepIcon.DefaultIconSize + val iconPadding = POStepIcon.DefaultPadding + val stepStyle = POStepper.stepStyle( + style = style, + state = states[index] + ) + Column( + modifier = Modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + POStepIcon( + iconSize = iconSize, + padding = iconPadding, + style = stepStyle.icon + ) + if (index != steps.elements.lastIndex) { + val connectorStyle = POStepper.connectorStyle( + style = style, + states = states, + currentStepIndex = index + ) + Canvas( + modifier = Modifier + .requiredWidth(connectorStyle.width) + .fillMaxHeight() + .defaultMinSize(minHeight = 28.dp) + ) { + val pathEffect = connectorStyle.dashInterval?.toPx() + ?.let { dashIntervalPx -> + PathEffect.dashPathEffect( + intervals = floatArrayOf(dashIntervalPx, dashIntervalPx) + ) + } + drawLine( + color = connectorStyle.color, + start = Offset(x = 0f, y = 0f), + end = Offset(x = 0f, y = size.height), + strokeWidth = size.width, + pathEffect = pathEffect + ) + } + } + } + Column( + modifier = Modifier + .padding(start = spacing.space6) + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(spacing.space4) + ) { + val titleTextStyle = stepStyle.title.textStyle + POText( + text = step.title, + color = stepStyle.title.color, + style = titleTextStyle, + modifier = Modifier.padding( + top = POText.measuredPaddingTop( + textStyle = titleTextStyle, + componentHeight = iconSize + iconPadding * 2 + ) + ) + ) + POExpandableText( + text = step.description, + style = stepStyle.description, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +/** @suppress */ +@ProcessOutInternalApi +@Composable +@Preview(showBackground = true) +private fun POVerticalStepperPreview() { + POVerticalStepper( + steps = POImmutableList( + listOf( + POStepper.Step( + title = "Step 1", + description = "Description" + ), + POStepper.Step( + title = "Step 2", + description = "Description" + ), + POStepper.Step( + title = "Step 3", + description = "Description" + ), + POStepper.Step( + title = "Step 4", + description = "Description" + ) + ) + ), + activeStepIndex = 2 + ) +} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/style/POStepperStyle.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/style/POStepperStyle.kt new file mode 100644 index 000000000..5a7e50dbf --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/style/POStepperStyle.kt @@ -0,0 +1,52 @@ +package com.processout.sdk.ui.core.style + +import android.os.Parcelable +import androidx.annotation.ColorRes +import kotlinx.parcelize.Parcelize + +@Parcelize +data class POStepperStyle( + val pending: StepStyle, + val active: StepStyle, + val completed: StepStyle +) : Parcelable { + + @Parcelize + data class StepStyle( + val title: POTextStyle, + val description: POTextStyle, + val icon: IconStyle, + val connector: POStrokeStyle + ) : Parcelable + + @Parcelize + data class IconStyle( + @ColorRes + val backgroundColorResId: Int, + val border: Border?, + val halo: Halo?, + val checkmark: Checkmark? + ) : Parcelable { + + @Parcelize + data class Border( + val widthDp: Int, + @ColorRes + val colorResId: Int + ) : Parcelable + + @Parcelize + data class Halo( + val widthDp: Int, + @ColorRes + val colorResId: Int + ) : Parcelable + + @Parcelize + data class Checkmark( + val widthDp: Int, + @ColorRes + val colorResId: Int + ) : Parcelable + } +} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/style/POStrokeStyle.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/style/POStrokeStyle.kt new file mode 100644 index 000000000..d328f8667 --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/style/POStrokeStyle.kt @@ -0,0 +1,13 @@ +package com.processout.sdk.ui.core.style + +import android.os.Parcelable +import androidx.annotation.ColorRes +import kotlinx.parcelize.Parcelize + +@Parcelize +data class POStrokeStyle( + val widthDp: Int, + @ColorRes + val colorResId: Int, + val dashIntervalDp: Int? = null +) : Parcelable diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Colors.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Colors.kt index 53a166706..e6bb12761 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Colors.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Colors.kt @@ -21,7 +21,9 @@ data class POColors( data class Text( val primary: Color, val secondary: Color, + val tertiary: Color, val inverse: Color, + val positive: Color, val muted: Color, val placeholder: Color, val disabled: Color, @@ -92,7 +94,9 @@ val POLightColorPalette = POColors( text = Text( primary = Color(0xFF000000), secondary = Color(0xFF585A5F), + tertiary = Color(0xFF8A8D93), inverse = Color(0xFFFFFFFF), + positive = Color(0xFF139947), muted = Color(0xFF5B6576), placeholder = Color(0xFF707378), disabled = Color(0xFFADB5BD), @@ -153,7 +157,9 @@ val PODarkColorPalette = POColors( text = Text( primary = Color(0xFFFFFFFF), secondary = Color(0xFFC0C3C8), + tertiary = Color(0xFF8A8D93), inverse = Color(0xFF000000), + positive = Color(0xFF28DE6B), muted = Color(0xFFADB5BD), placeholder = Color(0xFFA7A9AF), disabled = Color(0xFF5B6576), diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/PONativeAlternativePaymentConfiguration.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/PONativeAlternativePaymentConfiguration.kt index af9b5087e..8ebed286b 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/PONativeAlternativePaymentConfiguration.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/PONativeAlternativePaymentConfiguration.kt @@ -380,8 +380,9 @@ data class PONativeAlternativePaymentConfiguration( val radioField: PORadioFieldStyle? = null, val checkbox: POCheckboxStyle? = null, val dropdownMenu: PODropdownMenuStyle? = null, - val actionsContainer: POActionsContainerStyle? = null, val dialog: PODialogStyle? = null, + val stepper: POStepperStyle? = null, + val actionsContainer: POActionsContainerStyle? = null, val background: POBackgroundStyle? = null, val message: POTextStyle? = null, val errorMessage: POTextStyle? = null, // TODO(v2): remove, not used @@ -452,8 +453,9 @@ data class PONativeAlternativePaymentConfiguration( radioField = null, checkbox = null, dropdownMenu = dropdownMenu, - actionsContainer = actionsContainer, dialog = dialog, + stepper = null, + actionsContainer = actionsContainer, background = background, message = message, errorMessage = errorMessage, diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/v2/NativeAlternativePaymentScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/v2/NativeAlternativePaymentScreen.kt index 24818bc48..27016d264 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/v2/NativeAlternativePaymentScreen.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/v2/NativeAlternativePaymentScreen.kt @@ -45,6 +45,7 @@ import com.processout.sdk.ui.core.component.field.dropdown.PODropdownField2 import com.processout.sdk.ui.core.component.field.phone.POPhoneNumberField import com.processout.sdk.ui.core.component.field.radio.PORadioField import com.processout.sdk.ui.core.component.field.text.POTextField2 +import com.processout.sdk.ui.core.component.stepper.POStepper import com.processout.sdk.ui.core.state.POActionState import com.processout.sdk.ui.core.state.POImmutableList import com.processout.sdk.ui.core.state.POPhoneNumberFieldState @@ -122,7 +123,7 @@ internal fun NativeAlternativePaymentScreen( horizontal = ProcessOutTheme.spacing.extraLarge, vertical = if (state is Capture) 0.dp else verticalSpacing ), - verticalArrangement = if (state is Capture) Arrangement.Top else Arrangement.Center, + verticalArrangement = if (state is Loading) Arrangement.Center else Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally ) { when (state) { @@ -714,8 +715,9 @@ internal object NativeAlternativePaymentScreen { val radioField: PORadioField.Style, val checkbox: POCheckbox.Style, val dropdownMenu: PODropdownField.MenuStyle, - val actionsContainer: POActionsContainer.Style, val dialog: PODialog.Style, + val stepper: POStepper.Style, + val actionsContainer: POActionsContainer.Style, val normalBackgroundColor: Color, val successBackgroundColor: Color, val message: AndroidTextView.Style, @@ -749,12 +751,15 @@ internal object NativeAlternativePaymentScreen { dropdownMenu = custom?.dropdownMenu?.let { PODropdownField.custom(style = it) } ?: PODropdownField.defaultMenu2, - actionsContainer = custom?.actionsContainer?.let { - POActionsContainer.custom(style = it) - } ?: POActionsContainer.default2, dialog = custom?.dialog?.let { PODialog.custom(style = it) } ?: PODialog.default, + stepper = custom?.stepper?.let { + POStepper.custom(style = it) + } ?: POStepper.default, + actionsContainer = custom?.actionsContainer?.let { + POActionsContainer.custom(style = it) + } ?: POActionsContainer.default2, normalBackgroundColor = custom?.background?.normalColorResId?.let { colorResource(id = it) } ?: colors.surface.default,