Skip to content

Instantly share code, notes, and snippets.

@webianks
Created November 26, 2025 16:45
Show Gist options
  • Select an option

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

Select an option

Save webianks/0d082b74533b549f62b110c7db929494 to your computer and use it in GitHub Desktop.
PL MiniApp - Black Friday Sale
package com.webianks.miniapps
import android.app.Activity
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
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.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
// Colors
val colorBg = Color(0xFFF6F2ED)
val colorSurface = Color(0xFFFFFFFF)
val colorTextPrimary = Color(0xFF211304)
val colorTextDisabled = Color(0xFF9A9795)
val colorTextAlt = Color(0xFFFFFFFF)
val colorDiscount = Color(0xFF7C1414)
// Typography
val HostGrotesk = FontFamily(
Font(R.font.host_grotesk_medium, FontWeight.Medium),
Font(R.font.host_grotesk_semibold, FontWeight.SemiBold)
)
val AppTypography = Typography(
titleLarge = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp
),
bodyMedium = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = HostGrotesk,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
),
)
// Theme Composable
private val LightColorScheme = lightColorScheme(
primary = colorTextPrimary,
onPrimary = colorTextAlt,
background = colorBg,
surface = colorSurface,
onBackground = colorTextPrimary,
onSurface = colorTextPrimary,
onSurfaceVariant = colorTextDisabled,
error = colorDiscount
)
@Composable
fun MiniAppsTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = LightColorScheme,
typography = AppTypography,
content = content
)
}
// Models
enum class Language(
val code: String,
val displayName: String,
@DrawableRes val flagRes: Int
) {
ENGLISH("en", "English", R.drawable.ic_english),
SPANISH("es", "Español", R.drawable.ic_spanish),
ARABIC("ar", "العربية", R.drawable.ic_arab)
}
data class ProductPrice(
val price: Double,
val oldPrice: Double,
val currencySymbol: String,
val isSymbolPrefix: Boolean // e.g., $100 vs 100 د.إ
)
data class Product(
val id: Int,
val names: Map<String, String>,
val prices: Map<String, ProductPrice>, // Keyed by Language code
val discountPercent: Int,
@DrawableRes val imageRes: Int
)
// Data Source
object DataRepository {
val products = listOf(
Product(
1,
mapOf(
"en" to "Guess VINCENT Sneakers",
"es" to "Zapatillas Guess VINCENT",
"ar" to "أحذية رياضية غيس فنسنت"
),
mapOf(
"en" to ProductPrice(100.0, 160.0, "$", true),
"es" to ProductPrice(92.0, 148.0, "€", false),
"ar" to ProductPrice(370.0, 599.0, "د.إ", false)
),
38,
R.drawable.guess_vincent_sneakers
),
Product(
2,
mapOf(
"en" to "Armani Exchange T-Shirt 2-pack",
"es" to "Pack de 2 Camisetas Armani Exchange",
"ar" to "حزمة من 2 تي شيرت من أرماني إكستشينج"
),
mapOf(
"en" to ProductPrice(35.0, 81.0, "$", true),
"es" to ProductPrice(32.0, 75.0, "€", false),
"ar" to ProductPrice(129.0, 299.0, "د.إ", false)
),
56,
R.drawable.armani_exchange_tshirt
),
Product(
3,
mapOf(
"en" to "Gant Cotton T-Shirt",
"es" to "Camiseta de Algodón Gant",
"ar" to "تي شيرت قطني من غانت"
),
mapOf(
"en" to ProductPrice(46.0, 57.0, "$", true),
"es" to ProductPrice(42.0, 52.0, "€", false),
"ar" to ProductPrice(169.0, 209.0, "د.إ", false)
),
19,
R.drawable.gant_cotton_tshirt
),
Product(
4,
mapOf(
"en" to "United Colors of Benetton Cotton Shirt",
"es" to "Camisa de Algodón United Colors of Benetton",
"ar" to "قميص قطني من يونايتد كولورز أوف بينيتون"
),
mapOf(
"en" to ProductPrice(27.0, 40.0, "$", true),
"es" to ProductPrice(25.0, 37.0, "€", false),
"ar" to ProductPrice(100.0, 149.0, "د.إ", false)
),
33,
R.drawable.benetton_cotton_shirt
),
Product(
5,
mapOf(
"en" to "United Colors of Benetton Linen Blend Polo",
"es" to "Polo de Lino United Colors of Benetton",
"ar" to "بولو بخليط الكتان من يونايتد كولورز أوف بينيتون"
),
mapOf(
"en" to ProductPrice(51.0, 75.0, "$", true),
"es" to ProductPrice(47.0, 70.0, "€", false),
"ar" to ProductPrice(190.0, 279.0, "د.إ", false)
),
32,
R.drawable.benetton_linen_polo
),
Product(
6,
mapOf(
"en" to "Daniel Wellington Watch",
"es" to "Reloj Daniel Wellington",
"ar" to "ساعة دانيال ويلينغتون"
),
mapOf(
"en" to ProductPrice(153.0, 177.0, "$", true),
"es" to ProductPrice(142.0, 165.0, "€", false),
"ar" to ProductPrice(565.0, 659.0, "د.إ", false)
),
13,
R.drawable.daniel_wellington_watch
),
Product(
7,
mapOf(
"en" to "Calvin Klein Polo",
"es" to "Polo Calvin Klein",
"ar" to "بولو كالفن كلاين"
),
mapOf(
"en" to ProductPrice(40.0, 103.0, "$", true),
"es" to ProductPrice(37.0, 96.0, "€", false),
"ar" to ProductPrice(150.0, 389.0, "د.إ", false)
),
61,
R.drawable.calvin_klein_polo
),
Product(
8,
mapOf(
"en" to "Columbia Skien Valley Outdoor Jacket",
"es" to "Chaqueta Outdoor Columbia Skien Valley",
"ar" to "سترة خارجية كولومبيا سكين فالي"
),
mapOf(
"en" to ProductPrice(86.0, 111.0, "$", true),
"es" to ProductPrice(80.0, 103.0, "€", false),
"ar" to ProductPrice(320.0, 409.0, "د.إ", false)
),
21,
R.drawable.columbia_outdoor_jacket
)
)
}
// VM and State
class MainViewModel : ViewModel() {
private val PREFS_NAME = "black_friday_prefs"
private val KEY_LANG = "selected_language"
private val _currentLanguage = MutableStateFlow(Language.ENGLISH)
val currentLanguage: StateFlow<Language> = _currentLanguage.asStateFlow()
fun loadLanguage(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val langCode = prefs.getString(KEY_LANG, Language.ENGLISH.code)
_currentLanguage.value = Language.values().find { it.code == langCode } ?: Language.ENGLISH
}
fun setLanguage(context: Context, language: Language) {
_currentLanguage.value = language
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_LANG, language.code).apply()
}
}
// Screen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BlackFridayApp()
}
}
}
@Composable
fun BlackFridayApp() {
val context = LocalContext.current
val viewModel: MainViewModel = viewModel()
LaunchedEffect(Unit) {
viewModel.loadLanguage(context)
}
val currentLang by viewModel.currentLanguage.collectAsState()
val layoutDirection = if (currentLang == Language.ARABIC) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
// Fix for Status Bar Icons (Light Background -> Dark Icons)
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
// Ensure icons are dark (e.g. black) because the top bar is white
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true
window.statusBarColor = colorSurface.toArgb()
window.navigationBarColor = colorSurface.toArgb()
}
}
// Wrap entire app in CompositionLocalProvider to handle RTL/LTR
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
MiniAppsTheme {
Scaffold(
topBar = { TopBar(currentLang, viewModel) },
containerColor = MaterialTheme.colorScheme.background
) { paddingValues ->
ProductList(
modifier = Modifier.padding(paddingValues),
currentLang = currentLang
)
}
}
}
}
// Components
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBar(currentLang: Language, viewModel: MainViewModel) {
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
CenterAlignedTopAppBar(
title = {
Text(
"SALE",
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = {
// no-op
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
IconButton(onClick = {
// no-op
}) {
Icon(
painterResource(R.drawable.ic_cart),
tint = colorTextPrimary,
contentDescription = "Cart"
)
}
Box {
Row(
modifier = Modifier
.clickable { expanded = true }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = currentLang.flagRes),
contentDescription = currentLang.displayName,
modifier = Modifier.size(24.dp)
)
}
DropdownMenu(
modifier = Modifier.background(colorSurface),
expanded = expanded,
onDismissRequest = { expanded = false }
) {
Language.entries.forEach { lang ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = lang.flagRes),
contentDescription = lang.displayName,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(lang.displayName)
}
},
onClick = {
viewModel.setLanguage(context, lang)
expanded = false
},
trailingIcon = {
if (lang == currentLang) {
Icon(Icons.Default.Check, contentDescription = null)
}
}
)
}
}
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = colorBg
)
)
}
@Composable
fun ProductList(modifier: Modifier = Modifier, currentLang: Language) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.fillMaxSize()
) {
items(DataRepository.products) { product ->
ProductCard(product, currentLang)
}
}
}
@Composable
fun ProductCard(product: Product, lang: Language) {
val name = product.names[lang.code] ?: product.names["en"] ?: ""
val priceData = product.prices[lang.code] ?: product.prices["en"]!!
// Format prices
val priceString = if (priceData.isSymbolPrefix) {
"${priceData.currencySymbol}${priceData.price.toInt()}"
} else {
"${priceData.price.toInt()} ${priceData.currencySymbol}"
}
val oldPriceString = if (priceData.isSymbolPrefix) {
"${priceData.currencySymbol}${priceData.oldPrice.toInt()}"
} else {
"${priceData.oldPrice.toInt()} ${priceData.currencySymbol}"
}
Card(
shape = RoundedCornerShape(4.dp),
colors = CardDefaults.cardColors(containerColor = colorSurface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
modifier = Modifier.fillMaxWidth()
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.8f)
.padding(4.dp)
) {
Image(
painter = painterResource(id = product.imageRes),
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Icon(
imageVector = Icons.Default.FavoriteBorder,
contentDescription = "Favorite",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(24.dp)
)
Surface(
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 0.dp, bottom = 12.dp)
) {
Text(
text = "-${product.discountPercent}%",
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
modifier = Modifier.height(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = priceString,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = oldPriceString,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textDecoration = TextDecoration.LineThrough,
modifier = Modifier.padding(bottom = 2.dp)
)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun ProductCardPreview() {
val product = Product(
1,
mapOf(
"en" to "Guess VINCENT Sneakers",
"es" to "Zapatillas Guess VINCENT",
"ar" to "أحذية رياضية غيس فنسنت"
),
mapOf(
"en" to ProductPrice(100.0, 160.0, "$", true),
"es" to ProductPrice(92.0, 148.0, "€", false),
"ar" to ProductPrice(370.0, 599.0, "د.إ", false)
),
38,
R.drawable.guess_vincent_sneakers
)
MiniAppsTheme {
ProductCard(product = product, lang = Language.ENGLISH)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment