diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index 46f21d18..b69a2679 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -137,6 +137,11 @@ class MainActivity : AppCompatActivity() { */ private fun deepLinkRouteFromIntent(intent: Intent?): String? { intent ?: return null + // Foreground-posted notifications (AppMessagingService → PartnerNotificationManager) carry the + // already-resolved app route as an extra. Prefer it so routing never depends on per-route + // navDeepLink registration — game/challenge/date/capsule routes aren't registered as Uri + // deep links, so those taps used to fall through to Home when the app was foregrounded. + intent.getStringExtra("app_route")?.takeIf { it.isNotBlank() }?.let { return it } if (intent.data != null) return null val type = intent.getStringExtra("type") ?: return null val coupleId = intent.getStringExtra("couple_id") ?: "" diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 27290d1a..c48a93bb 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -557,6 +557,22 @@ fun AppNavigation( app.closer.ui.components.MessageBubbleOverlay( onOpen = { c, conv -> navigateRoute(AppRoute.conversation(c, conv)) } ) + + // Prominent in-app banner when a partner starts a game while this app is foregrounded + // (background delivery uses the OS notification). Tap "Join" → the game route, which + // auto-joins the couple's active session on open. + app.closer.ui.components.GamePromptBanner( + onJoin = { gameType, sessionId -> + val route = app.closer.notifications.PartnerNotificationType.PARTNER_STARTED_GAME + .routeFor( + app.closer.notifications.PartnerNotificationPayload( + gameType = gameType, + gameSessionId = sessionId + ) + ) + navigateRoute(route) + } + ) } } diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index ac7e5bcf..3f939537 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -23,6 +23,8 @@ class AppMessagingService : FirebaseMessagingService() { @Inject lateinit var partnerNotificationManager: PartnerNotificationManager @Inject lateinit var activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor @Inject lateinit var messageBubbleController: app.closer.notifications.MessageBubbleController + @Inject lateinit var gamePromptController: app.closer.notifications.GamePromptController + @Inject lateinit var activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -69,6 +71,23 @@ class AppMessagingService : FirebaseMessagingService() { return } + // Partner started a game while we're foregrounded → surface the prominent in-app banner + // instead of the OS notification (mirrors chat). Skip if we're already viewing that game live. + if (type == "partner_started_game") { + val sessionId = message.data["game_session_id"] + val alreadyViewing = sessionId != null && sessionId == activeGameSessionMonitor.activeSessionId + if (!alreadyViewing) { + gamePromptController.show( + coupleId = coupleId, + gameType = message.data["game_type"] ?: "", + gameSessionId = sessionId, + starterName = message.data["starter_name"], + avatarUrl = message.data["sender_avatar_url"] + ) + } + return + } + serviceScope.launch { runCatching { partnerNotificationManager.handleRemote( diff --git a/app/src/main/java/app/closer/notifications/GamePromptController.kt b/app/src/main/java/app/closer/notifications/GamePromptController.kt new file mode 100644 index 00000000..eecd5ece --- /dev/null +++ b/app/src/main/java/app/closer/notifications/GamePromptController.kt @@ -0,0 +1,41 @@ +package app.closer.notifications + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * State for the in-app "partner started a game" banner. Shown over the app's own UI when a game + * start arrives while the app is FOREGROUNDED (background delivery uses the OS notification). Mirrors + * [MessageBubbleController] — the foreground FCM path posts here, the root UI observes [prompt]. + */ +data class IncomingGamePrompt( + val coupleId: String, + val gameType: String, + val gameSessionId: String? = null, + val starterName: String? = null, + val avatarUrl: String? = null +) + +@Singleton +class GamePromptController @Inject constructor() { + private val _prompt = MutableStateFlow(null) + val prompt: StateFlow = _prompt.asStateFlow() + + fun show( + coupleId: String, + gameType: String, + gameSessionId: String?, + starterName: String?, + avatarUrl: String? + ) { + if (coupleId.isBlank() || gameType.isBlank()) return + _prompt.value = IncomingGamePrompt(coupleId, gameType, gameSessionId, starterName, avatarUrl) + } + + fun dismiss() { + _prompt.value = null + } +} diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index 3e40dee9..b09e0de7 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -130,6 +130,10 @@ class PartnerNotificationManager @Inject constructor( val intent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW data = deepLinkUri + // Also carry the resolved route so MainActivity can navigate without relying on a + // per-route navDeepLink registration (game/challenge/date/capsule routes aren't + // registered as Uri deep links). See MainActivity.deepLinkRouteFromIntent. + putExtra("app_route", route) flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP } val pendingIntent = PendingIntent.getActivity( diff --git a/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt b/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt new file mode 100644 index 00000000..01b08aa4 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/GamePromptBanner.kt @@ -0,0 +1,202 @@ +package app.closer.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import app.closer.domain.model.GameType +import app.closer.notifications.GamePromptController +import app.closer.notifications.IncomingGamePrompt +import app.closer.ui.theme.CloserPalette +import coil.compose.AsyncImage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import javax.inject.Inject + +@HiltViewModel +class GamePromptViewModel @Inject constructor( + private val controller: GamePromptController +) : ViewModel() { + val prompt = controller.prompt + fun dismiss() = controller.dismiss() +} + +private fun gameDisplayName(gameType: String): String = when (gameType) { + GameType.WHEEL -> "Spin the Wheel" + GameType.THIS_OR_THAT -> "This or That" + GameType.HOW_WELL -> "How Well Do You Know Me" + GameType.DESIRE_SYNC -> "Desire Sync" + else -> "a game" +} + +/** + * Prominent in-app banner shown over the app's own UI when a partner starts a game while the app is + * FOREGROUNDED (background delivery uses the OS notification). Slides in from the top, tap "Join" to + * jump straight into the game (each game screen auto-joins the couple's active session on open). + * Mirrors the chat [MessageBubbleOverlay] foreground pattern. Auto-dismisses after a few seconds. + */ +@Composable +fun GamePromptBanner( + onJoin: (gameType: String, gameSessionId: String?) -> Unit, + viewModel: GamePromptViewModel = hiltViewModel() +) { + val prompt by viewModel.prompt.collectAsState() + + // Retain the last prompt so the slide-out animation still has data to render. + var last by remember { mutableStateOf(null) } + LaunchedEffect(prompt) { if (prompt != null) last = prompt } + + // Auto-dismiss a few seconds after it appears (it's a transient nudge, not a persistent card). + LaunchedEffect(prompt) { + if (prompt != null) { + delay(9000) + viewModel.dismiss() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = prompt != null, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = Modifier.align(Alignment.TopCenter) + ) { + val p = last ?: return@AnimatedVisibility + GamePromptCard( + prompt = p, + onJoin = { onJoin(p.gameType, p.gameSessionId); viewModel.dismiss() }, + onDismiss = { viewModel.dismiss() } + ) + } + } +} + +@Composable +private fun GamePromptCard( + prompt: IncomingGamePrompt, + onJoin: () -> Unit, + onDismiss: () -> Unit +) { + val name = prompt.starterName?.takeIf { it.isNotBlank() } ?: "Your partner" + Row( + modifier = Modifier + .statusBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxWidth() + .shadow(10.dp, RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.linearGradient( + listOf(CloserPalette.PurpleDeep, CloserPalette.PurpleRich) + ) + ) + .border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(20.dp)) + .padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Partner avatar (falls back to a play glyph). + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .border(1.5.dp, Color.White.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + val avatar = prompt.avatarUrl + if (!avatar.isNullOrBlank()) { + AsyncImage( + model = avatar, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp).clip(CircleShape) + ) + } else { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } + } + + Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text( + text = "$name started a game", + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.85f) + ) + Text( + text = gameDisplayName(prompt.gameType), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + color = Color.White + ) + } + + Button( + onClick = onJoin, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = CloserPalette.PurpleDeep + ), + shape = RoundedCornerShape(14.dp) + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + text = "Join", + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(start = 4.dp) + ) + } + + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Dismiss", + tint = Color.White.copy(alpha = 0.8f), + modifier = Modifier.size(20.dp) + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 823ea2d8..18d3f617 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -27,6 +27,8 @@ import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.isCloserDarkTheme import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -47,6 +49,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.ButtonDefaults import app.closer.domain.model.OutcomeDay import app.closer.ui.components.OutcomeCheckInDialog import androidx.compose.material3.Icon @@ -318,7 +322,12 @@ private fun HomeContent( state.pendingActions.takeIf { it.isNotEmpty() }?.let { pending -> WaitingForYouSection( actions = pending, - onAction = onPendingActionSelected + partnerName = state.partnerName, + waitingGameType = state.waitingGameType, + onAction = onPendingActionSelected, + // The game-waiting hero joins the specific waiting game directly (not the + // generic Play hub the pending-card fallback would use). + onJoinGame = { onNavigate(state.waitingGameRoute ?: AppRoute.PLAY) } ) } @@ -1241,20 +1250,115 @@ private fun CategoryMiniCard( @Composable private fun WaitingForYouSection( actions: List, - onAction: (PendingActionCard) -> Unit + partnerName: String?, + waitingGameType: String?, + onAction: (PendingActionCard) -> Unit, + onJoinGame: () -> Unit ) { + val gameCard = actions.firstOrNull { it.target == HomeActionTarget.Game } + val others = actions.filter { it.target != HomeActionTarget.Game } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = "Waiting for you", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) - actions.take(3).forEach { card -> + // The game-waiting prompt gets a bold hero treatment (promoted to the top) and joins the + // specific waiting game directly. + if (gameCard != null) { + GameWaitingHeroCard( + partnerName = partnerName, + gameName = gameDisplayName(waitingGameType), + onJoin = onJoinGame + ) + } + others.take(3).forEach { card -> PendingActionCardView(card = card, onClick = { onAction(card) }) } } } +private fun gameDisplayName(gameType: String?): String = when (gameType) { + "wheel" -> "Spin the Wheel" + "this_or_that" -> "This or That" + "how_well" -> "How Well Do You Know Me" + "desire_sync" -> "Desire Sync" + else -> "a game together" +} + +/** Bold, eye-catching "your partner is waiting to play " hero with a direct Join CTA. */ +@Composable +private fun GameWaitingHeroCard( + partnerName: String?, + gameName: String, + onJoin: () -> Unit +) { + val name = partnerName?.takeIf { it.isNotBlank() } ?: "Your partner" + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CloserRadii.Card)) + .background( + Brush.linearGradient(listOf(CloserPalette.PurpleDeep, CloserPalette.PurpleRich)) + ) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .border(1.5.dp, Color.White.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = "$name is waiting to play", + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.85f) + ) + Text( + text = gameName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = Color.White + ) + } + } + Button( + onClick = onJoin, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = CloserPalette.PurpleDeep + ), + shape = RoundedCornerShape(CloserRadii.Button) + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Join the game", + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(start = 6.dp) + ) + } + } +} + @Composable private fun PendingActionCardView( card: PendingActionCard, diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 8b33bd1a..42293976 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -149,6 +149,8 @@ data class HomeUiState( // The route of the active game waiting for this user, so the Home "Play now" CTA // resumes that specific game instead of dumping on the generic Play hub (B-002). val waitingGameRoute: String? = null, + // The waiting game's type (e.g. "wheel"), so the Home card can name the game ("… in Spin the Wheel"). + val waitingGameType: String? = null, val hasActiveChallenge: Boolean = false, val hasUpcomingDatePlan: Boolean = false, val hasUnlockedCapsule: Boolean = false, @@ -265,6 +267,7 @@ class HomeViewModel @Inject constructor( // Retention signal fetches — run in parallel, failures silently default to false. var hasWaitingGame = false var waitingGameRoute: String? = null + var waitingGameType: String? = null var hasActiveChallenge = false var hasUpcomingDatePlan = false var hasUnlockedCapsule = false @@ -302,6 +305,7 @@ class HomeViewModel @Inject constructor( val (waitingSession, waitingRoute) = gameJob.await() hasWaitingGame = waitingSession != null waitingGameRoute = waitingRoute + waitingGameType = waitingSession?.gameType hasActiveChallenge = challengeJob.await() hasUpcomingDatePlan = dateJob.await() hasUnlockedCapsule = capsuleJob.await() @@ -321,6 +325,7 @@ class HomeViewModel @Inject constructor( needsRecovery = needsRecovery, hasWaitingGame = hasWaitingGame, waitingGameRoute = waitingGameRoute, + waitingGameType = waitingGameType, hasActiveChallenge = hasActiveChallenge, hasUpcomingDatePlan = hasUpcomingDatePlan, hasUnlockedCapsule = hasUnlockedCapsule, diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt index 8d220f99..2c03f16e 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt @@ -42,7 +42,8 @@ class WheelSessionViewModel @Inject constructor( private val sessionStore: LocalWheelSessionStore, private val repository: QuestionRepository, private val gameSessionManager: GameSessionManager, - private val answerDataSource: FirestoreWheelAnswerDataSource + private val answerDataSource: FirestoreWheelAnswerDataSource, + private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor ) : ViewModel() { private val sessionId: String = savedStateHandle["sessionId"] ?: "" @@ -56,9 +57,17 @@ class WheelSessionViewModel @Inject constructor( private var submitting = false init { + // Mark this game active so a foreground "partner started a game" banner is suppressed while + // the user is already playing this wheel session (mirrors the other game screens). + activeGameSessionMonitor.enter(sessionId) load() } + override fun onCleared() { + super.onCleared() + activeGameSessionMonitor.leave(sessionId) + } + private fun load() { viewModelScope.launch { val uid = gameSessionManager.currentUserId