package com.suno.android.common_ui.components.dialogs import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember 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.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontSynthesis import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import coil3.compose.AsyncImage import com.suno.android.common_core_utils.Id import com.suno.android.common_ui.components.indicators.HorizontalPagingIndicator import com.suno.android.common_ui.components.omni.CircleIconButton import com.suno.android.common_ui.models.Promo import com.suno.android.common_ui.theme.EditorialFontFamily import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.NeueMontrealFontFamily import com.suno.android.common_ui.theme.SunoTheme import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUIR private const val FIXED_ASPECT_RATIO = 1 / 2F private const val CORNER_RADIUS = 20 /** * Dialog that displays promotional banners with support for multiple promos using a horizontal pager. * * @param promos List of promotional banners to display * @param onDismiss Callback invoked when the dialog is dismissed * @param onSeen Callback invoked when 'user sees' a specific banner */ @Composable fun PromoDialog( promos: List, onDismiss: () -> Unit, onSeen: (Id) -> Unit, ) { val pagerState = rememberPagerState(pageCount = { promos.size }) val seenPromoIds: MutableSet> = remember { mutableSetOf() } LaunchedEffect(pagerState.settledPage) { val settledPromo = promos.getOrNull(pagerState.settledPage) if (settledPromo != null && settledPromo.id !in seenPromoIds) { onSeen(settledPromo.id) seenPromoIds.add(settledPromo.id) } } Dialog( onDismissRequest = onDismiss, ) { SubcomposeLayout( modifier = Modifier .clip(RoundedCornerShape(CORNER_RADIUS.dp)) .background(Color.Black) .aspectRatio(FIXED_ASPECT_RATIO, matchHeightConstraintsFirst = true), ) { parentConstraints -> val childConstraints = parentConstraints.copy( minWidth = 0, minHeight = 0, ) val promoHeaderPlaceable = subcompose("promo_header") { PromoHeader( pagerState = pagerState, promos = promos, onDismiss = onDismiss, ) }.firstOrNull()?.measure(childConstraints) val bottomContentPlaceable = subcompose("bottom content") { Column { // Animate CTA button changes with scale effect AnimatedContent( targetState = pagerState.currentPage, transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform() }, label = "cta_animation", ) { currentPage -> val promo = promos.getOrNull(currentPage) if (!promo?.ctaLabel.isNullOrBlank()) { Row( modifier = Modifier.fillMaxWidth() .heightIn(48.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { CtaButton( ctaLabel = promo.ctaLabel, ctaTextColor = promo.ctaTextColor, ctaBackgroundColor = promo.ctaBackgroundColor, onCtaClick = promo.onCtaClick, ) } } } // Show paging indicator only if there are multiple promos if (promos.size > 1) { Row( modifier = Modifier .fillMaxWidth() .heightIn(48.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { HorizontalPagingIndicator( pagerState = pagerState, activeColor = Color.Gray, inactiveColor = Color.Gray.copy(alpha = 0.2f), ) } } else { Spacer(modifier = Modifier.height(30.dp)) } } }.firstOrNull()?.measure(childConstraints) val horizontalPagerPlaceable = subcompose("pager") { HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), ) { page -> val promo = promos.getOrNull(page) promo?.let { PromoBackgroundAndTitles( promo = promo, contentPadding = PaddingValues(bottom = bottomContentPlaceable?.height?.toDp() ?: 0.dp), ) } } }.firstOrNull()?.measure(parentConstraints) val width = parentConstraints.maxWidth val height = parentConstraints.minHeight layout(width, height) { horizontalPagerPlaceable?.place(0, 0) promoHeaderPlaceable?.place(0, 0) bottomContentPlaceable?.place(0, height - bottomContentPlaceable.height) } } } } @Composable private fun PromoHeader( pagerState: PagerState, promos: List, onDismiss: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { AnimatedContent( targetState = pagerState.currentPage, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "tag_animation", modifier = Modifier.weight(1f), ) { currentPage -> val currentPromo = promos.getOrNull(currentPage) Row( modifier = Modifier.fillMaxWidth(), ) { if (!currentPromo?.tagText.isNullOrBlank()) { Text( modifier = Modifier .background( color = currentPromo.tagBackgroundColor ?: ExtendedTheme.colors.aukPink, shape = RoundedCornerShape(CORNER_RADIUS.dp), ) .padding(horizontal = 7.dp, vertical = 3.dp), text = currentPromo.tagText, style = TextStyle( fontFamily = NeueMontrealFontFamily, fontSize = 14.sp, lineHeight = 19.sp, fontWeight = FontWeight.Medium, fontSynthesis = FontSynthesis.None, ), color = currentPromo.tagTextColor ?: Color.Black, ) } } } CircleIconButton( vectorDrawableResId = CommonUIR.drawable.close, bgColor = Color.White, iconColor = Color.Black, contentDescription = stringResource(CommonResR.string.close), onClick = { onDismiss.invoke() }, ) } } @Composable private fun CtaButton( ctaLabel: String, ctaTextColor: Color?, ctaBackgroundColor: Color?, onCtaClick: () -> Unit, modifier: Modifier = Modifier, ) { TextButton( modifier = modifier .background( color = ctaBackgroundColor ?: Color.White, shape = RoundedCornerShape(32.dp), ) .padding(horizontal = 8.dp), onClick = { onCtaClick.invoke() }, ) { Text( text = ctaLabel, color = ctaTextColor ?: Color.Black, style = ExtendedTheme.typography.buttonSmallCta, ) } } @Composable private fun PromoBackgroundAndTitles( promo: Promo, contentPadding: PaddingValues = PaddingValues.Zero, ) { Box( modifier = Modifier.fillMaxSize(), ) { AsyncImage( model = promo.backgroundImageUrlMobile ?: promo.backgroundImageUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), placeholder = ColorPainter(promo.backgroundColor ?: Color.Black), error = null, contentScale = ContentScale.Crop, ) val hasTitle = !promo.title.isNullOrBlank() val hasSubtitle = !promo.subtitle.isNullOrBlank() if (hasTitle || hasSubtitle) { Column( modifier = Modifier .padding( start = 16.dp, end = 16.dp, ) .padding(contentPadding) .align(Alignment.BottomCenter), horizontalAlignment = Alignment.CenterHorizontally, ) { if (hasTitle) { Text( modifier = Modifier.padding(bottom = 12.dp), text = promo.title, style = TextStyle( fontFamily = EditorialFontFamily, fontSize = 32.sp, lineHeight = 36.sp, fontWeight = FontWeight.Light, fontSynthesis = FontSynthesis.None, ), color = promo.titleColor ?: Color.White, textAlign = TextAlign.Center, ) } if (hasSubtitle) { Text( modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), text = promo.subtitle, style = TextStyle( fontFamily = NeueMontrealFontFamily, fontSize = 16.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.16.sp, fontSynthesis = FontSynthesis.None, ), color = promo.subtitleColor ?: Color.White, textAlign = TextAlign.Center, ) } } } } } @Suppress("MagicNumber") private object FakePromo { val promos = listOf( // Test case: All elements present with standard length text Promo( id = Id("a"), backgroundImageUrl = null, backgroundColor = Color(0xFF141E47), tagText = "TAG TEXT", tagBackgroundColor = Color(0xFFfdA29c), tagTextColor = Color(0xFF101012), title = "Title Text", titleColor = Color.White, subtitle = "Subtitle Text", subtitleColor = Color.White, ctaLabel = "CTA Text", ctaBackgroundColor = Color(0xFFE8EAEE), ctaTextColor = Color(0xFF101012), onCtaClick = {}, ), // Test case: Minimal elements (no tag, no subtitle, no CTA) Promo( id = Id("b"), backgroundImageUrl = null, backgroundColor = Color(0xFF2C1E47), tagText = null, tagBackgroundColor = null, tagTextColor = null, title = "Title Only", titleColor = Color.White, subtitle = null, subtitleColor = null, ctaLabel = null, ctaBackgroundColor = null, ctaTextColor = null, onCtaClick = {}, ), // Test case: Extremely long text in all fields to test wrapping and overflow Promo( id = Id("c"), backgroundImageUrl = null, backgroundColor = Color(0xFF471E1E), tagText = "REALLY REALLY REALLY REALLY REALLY REALLY LONG TAG TEXT THAT SHOULD WRAP OR TRUNCATE", tagBackgroundColor = Color(0xFFFDAA42), tagTextColor = Color(0xFF101012), title = "This is an extremely long title that should test the layout behavior when titles " + "exceed normal length and potentially wrap to multiple lines in the dialog", titleColor = Color.White, subtitle = "This is an extremely long subtitle that should also test wrapping behavior when " + "subtitles are much longer than typical promotional text and need to display across " + "multiple lines without breaking the layout or causing overflow issues", subtitleColor = Color.White, ctaLabel = "Really Long CTA Button Text", ctaBackgroundColor = Color(0xFFE8EAEE), ctaTextColor = Color(0xFF101012), onCtaClick = {}, ), ) } @Preview @Composable private fun PromoDialog_SinglePromo_Preview() { SunoTheme { PromoDialog( promos = FakePromo.promos.take(1), onDismiss = {}, onSeen = {}, ) } } @Preview @Composable private fun PromoDialog_MultiplePromos_Preview() { SunoTheme { PromoDialog( promos = FakePromo.promos, onDismiss = {}, onSeen = {}, ) } } @Preview(device = Devices.FOLDABLE) @Composable private fun PromoDialog_MultiplePromos_Foldable_Preview() { SunoTheme { PromoDialog( promos = FakePromo.promos, onDismiss = {}, onSeen = {}, ) } }