package com.craigvg.lichun_android.ui.components import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.craigvg.lichun_android.domain.models.MessageEvent import com.craigvg.lichun_android.domain.models.Question import com.craigvg.lichun_android.ui.screens.dating.RelationshipEventModal import com.craigvg.lichun_android.ui.screens.events.EventModal import com.craigvg.lichun_android.ui.screens.monetization.TimeSkipSummaryScreen import com.craigvg.lichun_android.ui.theme.AppColors import com.craigvg.lichun_android.ui.theme.AppSpacing import com.craigvg.lichun_android.ui.theme.AppTypography import com.craigvg.lichun_android.viewmodel.GameStateViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle /** * Wraps content and renders modal overlays for server-pushed events * (questionEvent, messageEvent) on top of the current screen. */ @Composable fun ModalOverlayHost( gameStateViewModel: GameStateViewModel, content: @Composable () -> Unit ) { val currentQuestion by gameStateViewModel.currentQuestion.collectAsStateWithLifecycle() val currentMessageEvent by gameStateViewModel.currentMessageEvent.collectAsStateWithLifecycle() val currentRelationshipEvent by gameStateViewModel.currentRelationshipEvent.collectAsStateWithLifecycle() val showTimeSkipSummary by gameStateViewModel.showTimeSkipSummary.collectAsStateWithLifecycle() val lastTimeSkipSummary by gameStateViewModel.lastTimeSkipSummary.collectAsStateWithLifecycle() val offlineDigest by gameStateViewModel.offlineDigest.collectAsStateWithLifecycle() val player by gameStateViewModel.player.collectAsStateWithLifecycle() Box(modifier = Modifier.fillMaxSize()) { content() // Message event overlay AnimatedVisibility( visible = currentMessageEvent != null && currentQuestion == null, enter = fadeIn(), exit = fadeOut() ) { currentMessageEvent?.let { event -> MessageEventOverlay( event = event, onDismiss = { gameStateViewModel.dismissCurrentMessageEvent() }, onClaim = { gameStateViewModel.claimEvent(event) gameStateViewModel.dismissCurrentMessageEvent() } ) } } // Question event overlay (takes priority) AnimatedVisibility( visible = currentQuestion != null, enter = fadeIn(), exit = fadeOut() ) { currentQuestion?.let { question -> EventModal( questionId = question.eventId, question = question.question, answers = question.answers, title = null, image = question.image, characters = question.characters, gameStateViewModel = gameStateViewModel, onDismiss = { gameStateViewModel.dismissCurrentQuestion() } ) } } // Relationship event overlay AnimatedVisibility( visible = currentRelationshipEvent != null && currentQuestion == null, enter = fadeIn(), exit = fadeOut() ) { currentRelationshipEvent?.let { event -> RelationshipEventModal( event = event, onDismiss = { gameStateViewModel.dismissCurrentRelationshipEvent() }, onChoiceSelected = { choice -> gameStateViewModel.respondToRelationshipEvent(event.id, choice.id) } ) } } // Time skip summary overlay AnimatedVisibility( visible = showTimeSkipSummary, enter = fadeIn(), exit = fadeOut() ) { TimeSkipSummaryScreen( summary = lastTimeSkipSummary, gameStateViewModel = gameStateViewModel, onDismiss = { gameStateViewModel.hideTimeSkipSummary() } ) } // Welcome-back offline digest (shown once on connect for a living // character that was away; suppressed on the death/onboarding screens). val showDigest = offlineDigest != null && player.status != "dead" && player.status != "" && player.status != "new" && currentQuestion == null && currentMessageEvent == null AnimatedVisibility( visible = showDigest, enter = fadeIn(), exit = fadeOut() ) { offlineDigest?.let { digest -> WelcomeBackOverlay( digest = digest, onDismiss = { gameStateViewModel.clearOfflineDigest() } ) } } } } /** * Welcome-back card summarizing what happened while the player was offline: * time away, money/age change, and a few notable events. */ @Composable private fun WelcomeBackOverlay( digest: com.craigvg.lichun_android.domain.models.OfflineDigest, onDismiss: () -> Unit ) { Box( modifier = Modifier .fillMaxSize() .background(AppColors.modalOverlay) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {} ), contentAlignment = Alignment.Center ) { Card( modifier = Modifier .fillMaxWidth() .padding(AppSpacing.lg), shape = RoundedCornerShape(AppSpacing.largeCornerRadius), colors = CardDefaults.cardColors(containerColor = AppColors.surfaceElevated), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column( modifier = Modifier .fillMaxWidth() .padding(AppSpacing.lg), horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = "👋", fontSize = 40.sp) Spacer(modifier = Modifier.height(AppSpacing.sm)) Text( text = "Welcome Back!", style = AppTypography.title, color = AppColors.primaryText, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(AppSpacing.sm)) Text( text = "Here's what happened while you were away" + (formatTimeAway(digest.minutesAway)?.let { " ($it)" } ?: "") + ".", style = AppTypography.body, color = AppColors.secondaryText, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(AppSpacing.md)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { if (digest.ageYearsDelta != 0) { DigestStat( emoji = "🎂", value = "+${digest.ageYearsDelta}y", label = "Aged" ) } if (digest.moneyDelta != 0.0) { val sign = if (digest.moneyDelta >= 0) "+" else "-" DigestStat( emoji = "💰", value = "$sign$${kotlin.math.abs(digest.moneyDelta).toLong()}", label = "Money" ) } } if (digest.notableEvents.isNotEmpty()) { Spacer(modifier = Modifier.height(AppSpacing.md)) Column(modifier = Modifier.fillMaxWidth()) { digest.notableEvents.take(3).forEach { event -> Text( text = "• $event", style = AppTypography.caption, color = AppColors.primaryText, modifier = Modifier.padding(vertical = AppSpacing.xxs) ) } } } Spacer(modifier = Modifier.height(AppSpacing.lg)) Button( onClick = onDismiss, modifier = Modifier.fillMaxWidth().height(AppSpacing.buttonHeight), shape = RoundedCornerShape(AppSpacing.pillCornerRadius), colors = ButtonDefaults.buttonColors(containerColor = AppColors.primary) ) { Text("Continue", style = AppTypography.headline, color = Color.White) } } } } } @Composable private fun DigestStat(emoji: String, value: String, label: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(text = emoji, fontSize = 24.sp) Text(text = value, style = AppTypography.bodyBold, color = AppColors.primaryText) Text(text = label, style = AppTypography.caption, color = AppColors.secondaryText) } } private fun formatTimeAway(minutes: Int): String? { if (minutes <= 0) return null return when { minutes < 60 -> "$minutes min" minutes < 1440 -> "${minutes / 60} hr" else -> "${minutes / 1440} days" } } /** * Overlay card for messageEvent — shows title, body, optional cost/reward, dismiss button. */ @Composable private fun MessageEventOverlay( event: MessageEvent, onDismiss: () -> Unit, onClaim: () -> Unit ) { Box( modifier = Modifier .fillMaxSize() .background(AppColors.modalOverlay) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {} ), contentAlignment = Alignment.Center ) { Card( modifier = Modifier .fillMaxWidth() .padding(AppSpacing.lg), shape = RoundedCornerShape(AppSpacing.largeCornerRadius), colors = CardDefaults.cardColors(containerColor = AppColors.surfaceElevated), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column( modifier = Modifier .fillMaxWidth() .padding(AppSpacing.lg), horizontalAlignment = Alignment.CenterHorizontally ) { // Category emoji val emoji = when (event.category.name.lowercase()) { "career" -> "\uD83D\uDCBC" "social" -> "\u2764\uFE0F" "achievement" -> "\uD83C\uDFC6" "education" -> "\uD83C\uDF93" "health" -> "\uD83D\uDC9A" "finance" -> "\uD83D\uDCB5" "random" -> "\uD83C\uDFB2" "negative" -> "\uD83D\uDCC9" else -> "\u2139\uFE0F" } Text(text = emoji, fontSize = 40.sp) Spacer(modifier = Modifier.height(AppSpacing.md)) // Title event.title?.let { title -> Text( text = title, style = AppTypography.title, color = AppColors.primaryText, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(AppSpacing.sm)) } // Message body Text( text = event.message, style = AppTypography.body, color = AppColors.secondaryText, textAlign = TextAlign.Center ) val statusText = event.status?.trim()?.takeIf { it.isNotEmpty() } val categoryText = event.categoryKey?.trim()?.takeIf { it.isNotEmpty() } if (statusText != null || categoryText != null) { Spacer(modifier = Modifier.height(AppSpacing.sm)) Row( horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs), verticalAlignment = Alignment.CenterVertically ) { statusText?.let { status -> Text( text = status.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }, style = AppTypography.captionBold, color = AppColors.primary, modifier = Modifier .background( AppColors.primary.copy(alpha = 0.15f), CircleShape ) .padding(horizontal = AppSpacing.sm, vertical = 4.dp) ) } categoryText?.let { category -> Text( text = category.replace('_', ' ').replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }, style = AppTypography.caption, color = AppColors.secondaryText ) } } } // Cost/reward pills val costs = buildList { event.energyCost?.let { if (it != 0) add("\u26A1 $it Energy") } event.moneyCost?.let { if (it != 0.0) add("\uD83D\uDCB0 ${it.toInt()} Money") } event.diamondCost?.let { if (it != 0) add("\uD83D\uDC8E $it Diamonds") } event.affinityChange?.let { if (it != 0) add("\u2764\uFE0F ${if (it > 0) "+" else ""}$it Affinity") } } if (costs.isNotEmpty()) { Spacer(modifier = Modifier.height(AppSpacing.md)) Row( horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs), modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { costs.forEach { cost -> val isNegative = cost.contains("-") Text( text = cost, style = AppTypography.captionBold, color = if (isNegative) AppColors.error else AppColors.energy, modifier = Modifier .background( (if (isNegative) AppColors.error else AppColors.energy).copy(alpha = 0.15f), CircleShape ) .padding(horizontal = AppSpacing.sm, vertical = 4.dp) ) } } } Spacer(modifier = Modifier.height(AppSpacing.lg)) // Action buttons if (event.isClaimable && !event.claimed) { Button( onClick = onClaim, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(AppSpacing.cornerRadius), colors = ButtonDefaults.buttonColors(containerColor = AppColors.primary) ) { Text( text = "Claim", style = AppTypography.bodyBold, color = Color.White, modifier = Modifier.padding(vertical = 4.dp) ) } Spacer(modifier = Modifier.height(AppSpacing.xs)) } OutlinedButton( onClick = onDismiss, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(AppSpacing.cornerRadius) ) { Text( text = "Dismiss", style = AppTypography.body, color = AppColors.secondaryText, modifier = Modifier.padding(vertical = 4.dp) ) } } } } }