From b079f96d212b4dba39e1e6c5886d98f54c247c86 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 15:49:13 -0400 Subject: [PATCH] feat(ui): add Callout component for future use Signed-off-by: Brandon McAnsh --- .../internal/ui/previews/CalloutPreviews.kt | 171 ++++++++++++++++++ .../com/flipcash/app/core/ui/Callout.kt | 150 +++++++++++++++ .../main/res/drawable/ic_callout_warning.xml | 12 ++ .../kotlin/com/getcode/ui/core/Modifier.kt | 8 +- 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/ui/previews/CalloutPreviews.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/Callout.kt create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_callout_warning.xml diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/ui/previews/CalloutPreviews.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/ui/previews/CalloutPreviews.kt new file mode 100644 index 000000000..a91ee1989 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/ui/previews/CalloutPreviews.kt @@ -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 = {}, + ) + } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/Callout.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/Callout.kt new file mode 100644 index 000000000..6a757b0a2 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/Callout.kt @@ -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, + ) + } + } +} diff --git a/apps/flipcash/core/src/main/res/drawable/ic_callout_warning.xml b/apps/flipcash/core/src/main/res/drawable/ic_callout_warning.xml new file mode 100644 index 000000000..118936fa4 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_callout_warning.xml @@ -0,0 +1,12 @@ + + + diff --git a/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt b/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt index e0a55fdd7..53746789c 100644 --- a/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt +++ b/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt @@ -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 @@ -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, ) }