Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package com.flipcash.app.core.internal.ui.previews

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.flipcash.app.core.ui.Callout
import com.flipcash.app.theme.FlipcashPreview
import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.core.R
import com.getcode.theme.CodeTheme
import com.getcode.theme.extraLarge

@Composable
private fun WarningIcon() {
Icon(
painter = painterResource(R.drawable.ic_callout_warning),
contentDescription = null,
tint = CodeTheme.colors.warning,
modifier = Modifier.size(CodeTheme.dimens.staticGrid.x10),
)
}

@Composable
private fun LockIcon() {
Icon(
painter = painterResource(R.drawable.ic_lock),
contentDescription = null,
tint = CodeTheme.colors.textSecondary,
modifier = Modifier.size(CodeTheme.dimens.staticGrid.x10),
)
}

/** Shaped card — icon + dismiss + action (the contact-access prompt). */
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun CalloutShapedFullPreview() {
Column(
modifier = Modifier
.padding(16.dp)
.background(CodeTheme.colors.background),
) {
Callout(
title = "Allow Full Contact Access",
description = "Make sure you can send cash and identify people you know",
shape = CodeTheme.shapes.medium,
icon = { WarningIcon() },
actionLabel = "Settings",
onDismiss = {},
onAction = {},
)
}
}

/** Shaped card — informational, no dismiss, no action. */
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun CalloutInfoOnlyPreview() {
Column(
modifier = Modifier
.padding(16.dp)
.background(CodeTheme.colors.background),
) {
Callout(
title = "Payments are private",
description = "Only you and the recipient can see this transaction",
shape = CodeTheme.shapes.medium,
icon = { LockIcon() },
)
}
}

/** Shaped card — action only, no icon, no dismiss. */
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun CalloutActionOnlyPreview() {
Column(
modifier = Modifier
.padding(16.dp)
.background(CodeTheme.colors.background),
) {
Callout(
title = "Verify your email",
description = "Secure your account and enable recovery",
shape = CodeTheme.shapes.medium,
actionLabel = "Verify",
onAction = {},
)
}
}

/** Custom container color + shape, dismiss only. */
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun CalloutErrorBannerPreview() {
Column(
modifier = Modifier
.padding(16.dp)
.background(CodeTheme.colors.background),
) {
Callout(
title = "You're offline",
description = "Reconnecting…",
shape = CodeTheme.shapes.extraLarge,
containerColor = CodeTheme.colors.error,
icon = { WarningIcon() },
onDismiss = {},
)
}
}

/** Bare overload — no `shape`; the caller owns the surface. */
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun CalloutBareInCallerSurfacePreview() {
Column(
modifier = Modifier
.padding(16.dp)
.background(CodeTheme.colors.background),
) {
Surface(
color = CodeTheme.colors.surfaceVariant,
contentColor = CodeTheme.colors.textMain,
shape = CodeTheme.shapes.extraLarge,
) {
Callout(
title = "Bring your own container",
description = "No shape passed, so the caller's Surface drives the look",
modifier = Modifier.padding(CodeTheme.dimens.grid.x4),
icon = { LockIcon() },
actionLabel = "Got it",
onDismiss = {},
onAction = {},
)
}
}
}

/** Full-bleed banner — square corners via RectangleShape. */
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun CalloutFullBleedBannerPreview() {
Column(
modifier = Modifier.background(CodeTheme.colors.background),
) {
Callout(
title = "Update available",
description = "Tap to install the latest version",
shape = RectangleShape,
icon = { WarningIcon() },
actionLabel = "Update",
onAction = {},
)
}
}
150 changes: 150 additions & 0 deletions apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/Callout.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.flipcash.app.core.ui

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.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import com.flipcash.core.R
import com.getcode.theme.CodeTheme
import com.getcode.ui.core.unboundedClickable
import com.getcode.ui.theme.ButtonState
import com.getcode.ui.theme.CodeButton

/**
* Content-only callout: an icon, title, description, optional dismiss affordance, and
* optional action button, with no container of its own. The caller supplies the surface
* (background, padding, elevation) by wrapping this in whatever layout they need.
*
* Pass a [Shape] (see the sibling overload) to have the callout draw its own surface.
*/
@Composable
fun Callout(
title: String,
description: String,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit)? = null,
actionLabel: String? = null,
onDismiss: (() -> Unit)? = null,
onAction: (() -> Unit)? = null,
) {
CalloutContent(
title = title,
description = description,
modifier = modifier,
icon = icon,
actionLabel = actionLabel,
onDismiss = onDismiss,
onAction = onAction,
)
}

/**
* Surface-backed callout. Passing an explicit [shape] selects this overload, drawing the
* callout inside a [containerColor] surface clipped to [shape] with standard inset padding.
* Omit [shape] to fall back to the content-only overload and drive the container yourself.
*/
@Composable
fun Callout(
title: String,
description: String,
shape: Shape,
modifier: Modifier = Modifier,
containerColor: Color = CodeTheme.colors.surfaceVariant,
icon: (@Composable () -> Unit)? = null,
actionLabel: String? = null,
onDismiss: (() -> Unit)? = null,
onAction: (() -> Unit)? = null,
) {
CalloutContent(
title = title,
description = description,
modifier = modifier
.clip(shape)
.background(containerColor, shape)
.padding(CodeTheme.dimens.grid.x3),
icon = icon,
actionLabel = actionLabel,
onDismiss = onDismiss,
onAction = onAction,
)
}

@Composable
private fun CalloutContent(
title: String,
description: String,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit)? = null,
actionLabel: String? = null,
onDismiss: (() -> Unit)? = null,
onAction: (() -> Unit)? = null,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
) {
Row(
modifier = Modifier.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
verticalAlignment = Alignment.Top,
) {
if (icon != null) {
Box(modifier = Modifier.fillMaxHeight()) {
icon.invoke()
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
) {
Text(
text = title,
style = CodeTheme.typography.textMedium,
color = CodeTheme.colors.textMain,
)
Text(
text = description,
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
)
}
if (onDismiss != null) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Dismiss",
tint = CodeTheme.colors.textSecondary,
modifier = Modifier
.size(CodeTheme.dimens.staticGrid.x4)
.unboundedClickable(onClick = onDismiss),
)
}
}
if (actionLabel != null && onAction != null) {
CodeButton(
text = actionLabel,
modifier = Modifier.fillMaxWidth(),
buttonState = ButtonState.Filled10,
onClick = onAction,
)
}
}
}
12 changes: 12 additions & 0 deletions apps/flipcash/core/src/main/res/drawable/ic_callout_warning.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:pathData="M24,19.977V26.706M24,30.895V30.912M24,9.043L8.019,35.958H39.981L24,9.043Z"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:strokeColor="#FFA939"
android:strokeLineCap="square"/>
</vector>
8 changes: 7 additions & 1 deletion ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
Expand Down Expand Up @@ -85,12 +86,17 @@ fun Modifier.unboundedClickable(
) = this.composed {
val interaction = interactionSource ?: remember { MutableInteractionSource() }

val ripple = if (LocalInspectionMode.current) {
null
} else {
ripple(bounded = false, radius = rippleRadius)
}
clickable(
onClick = onClick,
enabled = enabled,
role = role,
interactionSource = interaction,
indication = ripple(bounded = false, radius = rippleRadius),
indication = ripple,
)
}

Expand Down
Loading