Created
November 26, 2025 16:45
-
-
Save webianks/0d082b74533b549f62b110c7db929494 to your computer and use it in GitHub Desktop.
PL MiniApp - Black Friday Sale
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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