feat(notif): foreground game-start banner + bold Home waiting hero — join specific game from both

This commit is contained in:
null 2026-06-26 20:04:11 -05:00
parent 6e79cd9704
commit 38fdc6d2cc
9 changed files with 409 additions and 4 deletions

View File

@ -137,6 +137,11 @@ class MainActivity : AppCompatActivity() {
*/ */
private fun deepLinkRouteFromIntent(intent: Intent?): String? { private fun deepLinkRouteFromIntent(intent: Intent?): String? {
intent ?: return null 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 if (intent.data != null) return null
val type = intent.getStringExtra("type") ?: return null val type = intent.getStringExtra("type") ?: return null
val coupleId = intent.getStringExtra("couple_id") ?: "" val coupleId = intent.getStringExtra("couple_id") ?: ""

View File

@ -557,6 +557,22 @@ fun AppNavigation(
app.closer.ui.components.MessageBubbleOverlay( app.closer.ui.components.MessageBubbleOverlay(
onOpen = { c, conv -> navigateRoute(AppRoute.conversation(c, conv)) } 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)
}
)
} }
} }

View File

@ -23,6 +23,8 @@ class AppMessagingService : FirebaseMessagingService() {
@Inject lateinit var partnerNotificationManager: PartnerNotificationManager @Inject lateinit var partnerNotificationManager: PartnerNotificationManager
@Inject lateinit var activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor @Inject lateinit var activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor
@Inject lateinit var messageBubbleController: app.closer.notifications.MessageBubbleController @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) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -69,6 +71,23 @@ class AppMessagingService : FirebaseMessagingService() {
return 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 { serviceScope.launch {
runCatching { runCatching {
partnerNotificationManager.handleRemote( partnerNotificationManager.handleRemote(

View File

@ -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<IncomingGamePrompt?>(null)
val prompt: StateFlow<IncomingGamePrompt?> = _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
}
}

View File

@ -130,6 +130,10 @@ class PartnerNotificationManager @Inject constructor(
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
data = deepLinkUri 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 flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
} }
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(

View File

@ -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<IncomingGamePrompt?>(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)
)
}
}
}

View File

@ -27,6 +27,8 @@ import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.isCloserDarkTheme import app.closer.ui.theme.isCloserDarkTheme
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Favorite
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LocalFireDepartment 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.domain.model.OutcomeDay
import app.closer.ui.components.OutcomeCheckInDialog import app.closer.ui.components.OutcomeCheckInDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -318,7 +322,12 @@ private fun HomeContent(
state.pendingActions.takeIf { it.isNotEmpty() }?.let { pending -> state.pendingActions.takeIf { it.isNotEmpty() }?.let { pending ->
WaitingForYouSection( WaitingForYouSection(
actions = pending, 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 @Composable
private fun WaitingForYouSection( private fun WaitingForYouSection(
actions: List<PendingActionCard>, actions: List<PendingActionCard>,
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)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text( Text(
text = "Waiting for you", text = "Waiting for you",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface 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) }) 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 <Game>" 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 @Composable
private fun PendingActionCardView( private fun PendingActionCardView(
card: PendingActionCard, card: PendingActionCard,

View File

@ -149,6 +149,8 @@ data class HomeUiState(
// The route of the active game waiting for this user, so the Home "Play now" CTA // 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). // resumes that specific game instead of dumping on the generic Play hub (B-002).
val waitingGameRoute: String? = null, 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 hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false, val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: 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. // Retention signal fetches — run in parallel, failures silently default to false.
var hasWaitingGame = false var hasWaitingGame = false
var waitingGameRoute: String? = null var waitingGameRoute: String? = null
var waitingGameType: String? = null
var hasActiveChallenge = false var hasActiveChallenge = false
var hasUpcomingDatePlan = false var hasUpcomingDatePlan = false
var hasUnlockedCapsule = false var hasUnlockedCapsule = false
@ -302,6 +305,7 @@ class HomeViewModel @Inject constructor(
val (waitingSession, waitingRoute) = gameJob.await() val (waitingSession, waitingRoute) = gameJob.await()
hasWaitingGame = waitingSession != null hasWaitingGame = waitingSession != null
waitingGameRoute = waitingRoute waitingGameRoute = waitingRoute
waitingGameType = waitingSession?.gameType
hasActiveChallenge = challengeJob.await() hasActiveChallenge = challengeJob.await()
hasUpcomingDatePlan = dateJob.await() hasUpcomingDatePlan = dateJob.await()
hasUnlockedCapsule = capsuleJob.await() hasUnlockedCapsule = capsuleJob.await()
@ -321,6 +325,7 @@ class HomeViewModel @Inject constructor(
needsRecovery = needsRecovery, needsRecovery = needsRecovery,
hasWaitingGame = hasWaitingGame, hasWaitingGame = hasWaitingGame,
waitingGameRoute = waitingGameRoute, waitingGameRoute = waitingGameRoute,
waitingGameType = waitingGameType,
hasActiveChallenge = hasActiveChallenge, hasActiveChallenge = hasActiveChallenge,
hasUpcomingDatePlan = hasUpcomingDatePlan, hasUpcomingDatePlan = hasUpcomingDatePlan,
hasUnlockedCapsule = hasUnlockedCapsule, hasUnlockedCapsule = hasUnlockedCapsule,

View File

@ -42,7 +42,8 @@ class WheelSessionViewModel @Inject constructor(
private val sessionStore: LocalWheelSessionStore, private val sessionStore: LocalWheelSessionStore,
private val repository: QuestionRepository, private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager, private val gameSessionManager: GameSessionManager,
private val answerDataSource: FirestoreWheelAnswerDataSource private val answerDataSource: FirestoreWheelAnswerDataSource,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() { ) : ViewModel() {
private val sessionId: String = savedStateHandle["sessionId"] ?: "" private val sessionId: String = savedStateHandle["sessionId"] ?: ""
@ -56,9 +57,17 @@ class WheelSessionViewModel @Inject constructor(
private var submitting = false private var submitting = false
init { 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() load()
} }
override fun onCleared() {
super.onCleared()
activeGameSessionMonitor.leave(sessionId)
}
private fun load() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val uid = gameSessionManager.currentUserId val uid = gameSessionManager.currentUserId