Skip to content

Instantly share code, notes, and snippets.

@webianks
Created November 28, 2025 11:06
Show Gist options
  • Select an option

  • Save webianks/f2175b097d25835964615feab8577d66 to your computer and use it in GitHub Desktop.

Select an option

Save webianks/f2175b097d25835964615feab8577d66 to your computer and use it in GitHub Desktop.
PL MiniApp - HiddenDiscount
package com.webianks.miniapps
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Color as AndroidColor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// --- Colors & Styles ---
val C_Background = Color(0xFFF6F2ED)
val C_Surface = Color(0xFFFFFFFF)
val C_Outline = Color(0xFFDFDDDB)
val C_Outline_Alt = Color(0xFFFFFFFF).copy(alpha = 0.2f)
val C_Text_Primary = Color(0xFF211304)
val C_Text_Disabled = Color(0xFF9A9795)
val C_Text_Alt = Color(0xFFFFFFFF)
val C_Text_On_Discount = Color(0xFFFFFFFF).copy(alpha = 0.7f)
val C_Discount_End = Color(0xFF7C1414)
val C_Discount_Start = Color(0xFF9E2A2A)
val C_Snackbar = Color(0xFF211304).copy(alpha = 0.8f)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(
AndroidColor.TRANSPARENT, AndroidColor.TRANSPARENT
),
navigationBarStyle = SystemBarStyle.light(
AndroidColor.TRANSPARENT, AndroidColor.TRANSPARENT
)
)
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(
typography = AppTypography
) {
HiddenDiscountScreen()
}
}
}
}
@Composable
fun HiddenDiscountScreen() {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// State
var promoCodeInput by remember { mutableStateOf("") }
var isDiscountApplied by remember { mutableStateOf(false) }
var showInvalidCodeError by remember { mutableStateOf(false) }
// Product Details
val originalPrice = 199
val discountedPrice = 149
val currentPrice = if (isDiscountApplied) discountedPrice else originalPrice
val offsetX = remember { Animatable(0f) }
fun copyToClipboard(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Promo Code", text)
clipboard.setPrimaryClip(clip)
scope.launch {
snackbarHostState.showSnackbar("Copied!")
}
}
fun applyPromoCode() {
if (promoCodeInput.trim().equals("BF2025", ignoreCase = true)) {
isDiscountApplied = true
showInvalidCodeError = false
scope.launch {
offsetX.animateTo(0f, animationSpec = tween(300))
snackbarHostState.showSnackbar("Discount applied")
}
} else {
showInvalidCodeError = true
}
}
Scaffold(
containerColor = C_Background,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState) { data ->
Snackbar(
modifier = Modifier
.height(32.dp)
.padding(horizontal = 16.dp),
containerColor = C_Snackbar,
contentColor = C_Text_Alt,
shape = RectangleShape
) {
Text(
text = data.visuals.message,
style = MaterialTheme.typography.bodyMedium,
color = C_Text_Alt,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
},
topBar = {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = C_Text_Primary
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Cart",
style = MaterialTheme.typography.titleLarge,
color = C_Text_Primary
)
Spacer(modifier = Modifier.weight(1f))
// Spacer to balance the title center
Spacer(modifier = Modifier.size(24.dp))
}
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
// --- Draggable Product Card Area ---
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.height(120.dp)
) {
val revealWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val velocityThreshold = with(LocalDensity.current) { 100.dp.toPx() }
val draggableState = rememberDraggableState { delta ->
scope.launch {
val newOffset = (offsetX.value + delta).coerceIn(-revealWidthPx, 0f)
offsetX.snapTo(newOffset)
}
}
val onDragStopped: suspend CoroutineScope.(Float) -> Unit = { velocity ->
val targetOffset =
if (offsetX.value < -revealWidthPx / 2 || velocity < -velocityThreshold) {
-revealWidthPx
} else {
0f
}
offsetX.animateTo(targetOffset, animationSpec = tween(300))
}
// 1. Background Layer (Promo Panel)
val ticketShape = remember { TicketShape(circleRadius = 8.dp) }
Box(
modifier = Modifier
.align(Alignment.CenterEnd) // Pin to right
.fillMaxHeight()
.fillMaxWidth()
.background(
brush = Brush.horizontalGradient(
colors = listOf(C_Discount_Start, C_Discount_End)
),
shape = ticketShape
)
.clip(ticketShape)
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = "BLACK FRIDAY SALE",
color = C_Text_On_Discount,
style = MaterialTheme.typography.labelSmall
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "25%",
color = C_Text_Alt,
style = MaterialTheme.typography.displayMedium,
lineHeight = 32.sp
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "OFF",
color = C_Text_Alt,
style = MaterialTheme.typography.bodyLarge,
)
}
}
VerticalDashedLine(
modifier = Modifier
.fillMaxHeight(0.6f)
.width(1.dp),
color = C_Outline_Alt,
)
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier
.border(1.dp, C_Outline_Alt, RectangleShape)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "BF2025",
color = C_Text_Alt,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(8.dp))
val context = LocalContext.current
Box(
modifier = Modifier
.size(40.dp)
.background(C_Surface, RectangleShape)
.clickableNoRipple { copyToClipboard(context, "BF2025") },
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_content_copy),
contentDescription = "Copy",
tint = C_Discount_End,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
// 2. Foreground Layer (Product Card)
Surface(
shape = RectangleShape,
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.fillMaxSize()
.draggable(
orientation = Orientation.Horizontal,
state = draggableState,
onDragStopped = onDragStopped
),
color = C_Surface,
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painterResource(R.drawable.img_earphones),
contentDescription = null,
modifier = Modifier.size(96.dp),
)
Spacer(modifier = Modifier.width(8.dp))
// Info Column
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Google Pixel Buds Pro",
style = MaterialTheme.typography.bodyLarge,
color = C_Text_Primary
)
Text(
text = "Noise-cancelling wireless earbuds with rich sound and long battery life.",
style = MaterialTheme.typography.bodySmall,
color = C_Text_Disabled,
maxLines = 2
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Quantity
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(20.dp)
) {
Box(
modifier = Modifier
.size(22.dp)
.border(1.dp, C_Outline, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center
) {
Text(
"—",
style = MaterialTheme.typography.bodyLarge,
color = C_Text_Disabled
)
}
Text(
"1",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = C_Text_Primary
)
Box(
modifier = Modifier
.size(22.dp)
.border(1.dp, C_Outline, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center
) {
Text(
"+",
style = MaterialTheme.typography.bodyLarge,
color = C_Text_Primary
)
}
}
// Price
Text(
text = "$$currentPrice",
style = MaterialTheme.typography.headlineSmall,
color = C_Text_Primary
)
}
}
}
}
}
// --- Bottom Section ---
Column(
modifier = Modifier.padding(bottom = 32.dp)
) {
// Promo Code Input
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
BasicTextField(
value = promoCodeInput,
onValueChange = {
promoCodeInput = it
if (showInvalidCodeError) showInvalidCodeError = false
if (isDiscountApplied) isDiscountApplied = false
},
singleLine = true,
textStyle = TextStyle(
fontFamily = MaterialTheme.typography.bodyLarge.fontFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
color = C_Text_Primary
),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = if (showInvalidCodeError) C_Discount_End else C_Outline
)
.padding(horizontal = 16.dp)
.height(56.dp),
contentAlignment = Alignment.CenterStart
) {
if (promoCodeInput.isEmpty()) {
Text(
"Enter promo code",
style = MaterialTheme.typography.bodyLarge,
color = C_Text_Disabled
)
}
innerTextField()
}
}
)
// Error Message
AnimatedVisibility(visible = showInvalidCodeError) {
Text(
text = "Invalid code",
color = C_Discount_End,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp, start = 4.dp)
)
}
}
// Apply Button
Button(
onClick = { applyPromoCode() },
enabled = !isDiscountApplied,
colors = ButtonDefaults.buttonColors(
containerColor = C_Outline,
contentColor = C_Text_Primary,
disabledContainerColor = C_Outline.copy(alpha = 0.5f),
disabledContentColor = C_Text_Disabled
),
shape = RectangleShape,
modifier = Modifier.height(56.dp)
) {
Text(
text = if (isDiscountApplied) "Applied" else "Apply",
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = C_Text_Primary
),
shape = RectangleShape
) {
Text(
"Buy",
style = MaterialTheme.typography.bodyLarge,
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
// Helper to remove ripple for cleaner custom buttons if desired
@Composable
fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier {
return this.then(
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
)
}
val HostGrotesk = FontFamily(
Font(R.font.host_grotesk_regular, FontWeight.Normal),
Font(R.font.host_grotesk_medium, FontWeight.Medium),
Font(R.font.host_grotesk_semibold, FontWeight.SemiBold),
Font(R.font.host_grotesk_bold, FontWeight.Bold)
)
val AppTypography = Typography(
// Display Medium
displayMedium = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
// Headline Small
headlineSmall = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
// Title Large
titleLarge = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
// Body Large
bodyLarge = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 20.sp,
letterSpacing = 0.5.sp
),
// Body Medium
bodyMedium = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
// Body Small
bodySmall = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 14.sp,
letterSpacing = 0.4.sp
),
// Label Small
labelSmall = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
class TicketShape(private val circleRadius: Dp) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Generic(path = getPath(size, density))
}
private fun getPath(size: Size, density: Density): Path {
val circleRadiusPx = with(density) { circleRadius.toPx() }
val halfCircle = circleRadiusPx
val middleX = size.width / 2
val path = Path().apply {
reset()
// Draw a rectangle
addRect(Rect(0f, 0f, size.width, size.height))
}
val cutoutPath = Path().apply {
// top cutout
addOval(
Rect(
left = middleX - halfCircle,
top = -halfCircle,
right = middleX + halfCircle,
bottom = halfCircle
)
)
// bottom cutout
addOval(
Rect(
left = middleX - halfCircle,
top = size.height - halfCircle,
right = middleX + halfCircle,
bottom = size.height + halfCircle
)
)
}
return Path.combine(
operation = PathOperation.Difference,
path1 = path,
path2 = cutoutPath
)
}
}
@Composable
fun VerticalDashedLine(
modifier: Modifier = Modifier,
color: Color = Color.White,
strokeWidth: Dp = 1.dp,
dashWidth: Dp = 4.dp,
gapWidth: Dp = 4.dp
) {
Canvas(modifier = modifier) {
val pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(dashWidth.toPx(), gapWidth.toPx()),
phase = 0f
)
drawLine(
color = color,
start = size.copy(width = 0f, height = 0f).let { androidx.compose.ui.geometry.Offset(it.width / 2, 0f) },
end = size.copy(width = 0f, height = size.height).let { androidx.compose.ui.geometry.Offset(it.width / 2, it.height) },
strokeWidth = strokeWidth.toPx(),
pathEffect = pathEffect
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment