feat(notif): foreground game-start banner + bold Home waiting hero — join specific game from both
This commit is contained in:
parent
6e79cd9704
commit
38fdc6d2cc
|
|
@ -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") ?: ""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue