package com.example.gravitydemo import android.content.ClipData import android.content.ClipboardManager import android.content.Context 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.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.graphics.Color import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import ai.gravityfield.gravity_sdk.GravitySDK import ai.gravityfield.gravity_sdk.models.Action import ai.gravityfield.gravity_sdk.models.CampaignContent import ai.gravityfield.gravity_sdk.models.ContentImpressionEngagement import ai.gravityfield.gravity_sdk.models.ContentVisibleImpressionEngagement import ai.gravityfield.gravity_sdk.models.Element import ai.gravityfield.gravity_sdk.models.ElementType import ai.gravityfield.gravity_sdk.models.GravityContentAlignment import ai.gravityfield.gravity_sdk.models.GravityMargin import ai.gravityfield.gravity_sdk.models.GravityPadding import ai.gravityfield.gravity_sdk.models.OnClickModel import ai.gravityfield.gravity_sdk.models.PageContext import ai.gravityfield.gravity_sdk.models.ProductClickEngagement import ai.gravityfield.gravity_sdk.models.ProductContainerType import ai.gravityfield.gravity_sdk.models.ProductVisibleImpressionEngagement import ai.gravityfield.gravity_sdk.models.Slot import ai.gravityfield.gravity_sdk.models.Style import ai.gravityfield.gravity_sdk.network.Campaign import kotlinx.coroutines.delay import org.json.JSONObject import kotlin.math.max @Composable fun ServerDrivenRecommendationWidget( selector: String, pageContext: PageContext, onOpenUrl: (String) -> Unit, onOpenDeeplink: (String) -> Unit, onOpenProduct: (Map) -> Unit, ) { var campaign by remember { mutableStateOf(null) } var content by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var hasSentContentImpression by remember { mutableStateOf(false) } var hasSentContentVisibleImpression by remember { mutableStateOf(false) } LaunchedEffect(selector, pageContext) { isLoading = true val response = runCatching { GravitySDK.instance.getContentBySelector( selector = JSONObject.quote(selector), pageContext = pageContext, ) }.getOrNull() campaign = response?.data?.firstOrNull() content = campaign?.payload?.firstOrNull()?.contents?.firstOrNull() hasSentContentImpression = false hasSentContentVisibleImpression = false isLoading = false } if (isLoading) { CircularProgressIndicator() return } val resolvedCampaign = campaign ?: return val resolvedContent = content ?: return val containerStyle = resolvedContent.variables?.frameUI?.container?.style ?: Style.empty val containerAlignment = containerStyle.contentAlignment?.toHorizontalAlignment() ?: Alignment.Start LaunchedEffect(resolvedContent.contentId) { if (!hasSentContentImpression) { hasSentContentImpression = true GravitySDK.instance.sendContentEngagement( ContentImpressionEngagement(resolvedContent, resolvedCampaign) ) } } VisibleImpressionBox( modifier = Modifier.fillMaxWidth(), onVisible = { if (!hasSentContentVisibleImpression) { hasSentContentVisibleImpression = true GravitySDK.instance.sendContentEngagement( ContentVisibleImpressionEngagement(resolvedContent, resolvedCampaign) ) } } ) { Column( modifier = Modifier .fillMaxWidth() .applyGravitySize(containerStyle) .clip(RoundedCornerShape((containerStyle.cornerRadius ?: 0.0).dp)) .background( color = containerStyle.backgroundColor ?: Color.Transparent, shape = RoundedCornerShape((containerStyle.cornerRadius ?: 0.0).dp) ) .padding(containerStyle.padding.toPaddingValues()) .padding(containerStyle.margin.toPaddingValues()), horizontalAlignment = containerAlignment, ) { resolvedContent.variables?.elements.orEmpty().orEmpty().forEach { element -> RenderElement( element = element, content = resolvedContent, campaign = resolvedCampaign, onOpenUrl = onOpenUrl, onOpenDeeplink = onOpenDeeplink, onOpenProduct = onOpenProduct, ) } } } } @Composable private fun ColumnScope.RenderElement( element: Element, content: CampaignContent, campaign: Campaign, onOpenUrl: (String) -> Unit, onOpenDeeplink: (String) -> Unit, onOpenProduct: (Map) -> Unit, ) { val context = LocalContext.current val style = element.style when (element.type) { ElementType.TEXT -> { Text( text = element.text.orEmpty(), modifier = Modifier .fillMaxWidth() .padding(style.margin.toPaddingValues()) .then( if (element.onClick != null) { Modifier.clickable { handleOnClick( context = context, onClick = element.onClick, onOpenUrl = onOpenUrl, onOpenDeeplink = onOpenDeeplink, ) } } else { Modifier } ), color = style.textColor ?: Color.Unspecified, fontSize = style.fontSize ?: TextUnit.Unspecified, fontWeight = style.fontWeight ?: FontWeight.Normal, textAlign = style.contentAlignment.toTextAlign(), ) } ElementType.IMAGE -> { val src = element.src ?: return RemoteImage( url = src, modifier = Modifier .fillMaxWidth() .height((style.size?.height ?: 120.0).dp) .padding(style.margin.toPaddingValues()) .then( if (element.onClick != null) { Modifier.clickable { handleOnClick( context = context, onClick = element.onClick, onOpenUrl = onOpenUrl, onOpenDeeplink = onOpenDeeplink, ) } } else { Modifier } ) ) } ElementType.BUTTON -> { Button( onClick = { handleOnClick( context = context, onClick = element.onClick, onOpenUrl = onOpenUrl, onOpenDeeplink = onOpenDeeplink, ) }, modifier = Modifier .fillMaxWidth() .padding(style.margin.toPaddingValues()), colors = ButtonDefaults.buttonColors( containerColor = style.backgroundColor ?: Color(0xFF111827), contentColor = style.textColor ?: Color.White, ) ) { Text( text = element.text.orEmpty(), fontSize = style.fontSize ?: TextUnit.Unspecified, fontWeight = style.fontWeight ?: FontWeight.Normal, ) } } ElementType.SPACER -> { val weight = style.weight if (weight != null) { Spacer(modifier = Modifier.weight(weight)) } else { Spacer(modifier = Modifier.height((style.size?.height ?: 12.0).dp)) } } ElementType.PRODUCTS_CONTAINER -> { val slots = content.products?.slots.orEmpty() when (style.productContainerType ?: ProductContainerType.ROW) { ProductContainerType.ROW -> { LazyRow( modifier = Modifier .fillMaxWidth() .padding(style.margin.toPaddingValues()), contentPadding = style.padding.toPaddingValues(), horizontalArrangement = Arrangement.spacedBy((style.rowSpacing ?: 12).dp), ) { itemsIndexed( items = slots, key = { itemIndex, slot -> productKey(slot, itemIndex) } ) { itemIndex, slot -> ProductCard( slot = slot, content = content, campaign = campaign, onOpenProduct = onOpenProduct, ) } } } ProductContainerType.GRID -> { LazyVerticalGrid( modifier = Modifier .fillMaxWidth() .padding(style.margin.toPaddingValues()) .then( style.size?.height?.let { Modifier.height(it.dp) } ?: Modifier ), columns = GridCells.Fixed(max(style.gridColumns ?: 3, 1)), contentPadding = style.padding.toPaddingValues(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), userScrollEnabled = false, ) { itemsIndexed( items = slots, key = { itemIndex, slot -> productKey(slot, itemIndex) } ) { itemIndex, slot -> ProductCard( slot = slot, content = content, campaign = campaign, onOpenProduct = onOpenProduct, ) } } } } } else -> Unit } } @Composable private fun ProductCard( slot: Slot, content: CampaignContent, campaign: Campaign, onOpenProduct: (Map) -> Unit, ) { VisibleImpressionBox( modifier = Modifier.width(180.dp), onVisible = { GravitySDK.instance.sendProductEngagement( ProductVisibleImpressionEngagement(slot, content, campaign) ) } ) { Column( modifier = Modifier .fillMaxWidth() .background(Color.White, RoundedCornerShape(16.dp)) .clickable { GravitySDK.instance.sendProductEngagement( ProductClickEngagement(slot, content, campaign) ) onOpenProduct(slot.item) } .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { stringValue("imageUrl", slot.item)?.let { imageUrl -> RemoteImage( url = imageUrl, modifier = Modifier .fillMaxWidth() .height(160.dp) ) } Text( text = stringValue("name", slot.item) ?: "Unknown product", style = TextStyle(fontWeight = FontWeight.SemiBold), ) stringValue("price", slot.item)?.let { price -> Text(text = price, color = Color(0xFF667085)) } } } } @Composable private fun RemoteImage( url: String, modifier: Modifier = Modifier, ) { Box( modifier = modifier.background(Color(0xFFF2F4F7), RoundedCornerShape(12.dp)), contentAlignment = Alignment.Center, ) { Text( text = "Load with your image loader:\n$url", textAlign = TextAlign.Center, color = Color(0xFF475467), ) } } @Composable private fun VisibleImpressionBox( modifier: Modifier = Modifier, onVisible: () -> Unit, content: @Composable () -> Unit, ) { val view = LocalView.current var isVisible by remember { mutableStateOf(false) } var hasFired by remember { mutableStateOf(false) } LaunchedEffect(isVisible, hasFired) { if (!isVisible || hasFired) return@LaunchedEffect delay(1000) if (isVisible && !hasFired) { hasFired = true onVisible() } } Box( modifier = modifier.onGloballyPositioned { coordinates -> val windowRect = Rect(0f, 0f, view.width.toFloat(), view.height.toFloat()) val widgetRect = coordinates.boundsInWindow() val widgetSize = coordinates.size val intersection = widgetRect.intersect(windowRect) val visibleArea = intersection.width * intersection.height val fullArea = widgetSize.width * widgetSize.height isVisible = fullArea > 0 && visibleArea >= fullArea * 0.5f } ) { content() } } private fun handleOnClick( context: Context, onClick: OnClickModel?, onOpenUrl: (String) -> Unit, onOpenDeeplink: (String) -> Unit, ) { onClick ?: return when (onClick.action) { Action.FOLLOW_URL -> onClick.url?.let(onOpenUrl) Action.FOLLOW_DEEPLINK -> onClick.deeplink?.let(onOpenDeeplink) Action.COPY -> { val text = onClick.copyData ?: return val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("", text)) } else -> Unit } } private fun productKey(slot: Slot, index: Int): String { return slot.slotId ?: stringValue("sku", slot.item) ?: stringValue("id", slot.item) ?: "slot-$index" } private fun stringValue(key: String, item: Map): String? { val value = item[key] ?: return null return when (value) { is String -> value is Number -> value.toString() else -> null } } private fun GravityPadding?.toPaddingValues(): PaddingValues { return PaddingValues( start = ((this?.left ?: 0.0)).dp, top = ((this?.top ?: 0.0)).dp, end = ((this?.right ?: 0.0)).dp, bottom = ((this?.bottom ?: 0.0)).dp, ) } private fun GravityMargin?.toPaddingValues(): PaddingValues { return PaddingValues( start = ((this?.left ?: 0.0)).dp, top = ((this?.top ?: 0.0)).dp, end = ((this?.right ?: 0.0)).dp, bottom = ((this?.bottom ?: 0.0)).dp, ) } private fun Modifier.applyGravitySize(style: Style): Modifier { var result = this style.size?.width?.let { result = result.width(it.dp) } style.size?.height?.let { result = result.height(it.dp) } return result } private fun GravityContentAlignment?.toTextAlign(): TextAlign { return when (this) { GravityContentAlignment.CENTER -> TextAlign.Center GravityContentAlignment.END -> TextAlign.End else -> TextAlign.Start } }