feat: add 'Waiting for you' unfinished business dashboard (batch v1.0.3)

- PendingActionCard data class with priority-ordered cards
- 6 card types: reveal ready, partner answered, game, challenge, date, capsule
- Max 3 cards, highest priority first, deep link to correct screen
- Placeholder guards for game/challenge/date/capsule (wired in later batches)
- Compact cards with purple/pink palette, no private text leaked
This commit is contained in:
null 2026-06-19 22:31:11 -05:00
parent c38e83b8ee
commit 9828e73171
2 changed files with 159 additions and 3 deletions

View File

@ -0,0 +1,58 @@
package app.closer.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
/**
* Creates the Android notification channels used by partner-triggered notifications.
*
* Channels are idempotent; calling [createChannels] multiple times is safe.
*/
class NotificationChannelSetup(private val context: Context) {
companion object {
const val CHANNEL_REMINDERS = "reminders"
const val CHANNEL_PARTNER_ACTIONS = "partner_activity"
const val CHANNEL_GAMES = "game_activity"
@JvmStatic
fun createChannels(context: Context) {
NotificationChannelSetup(context).createChannels()
}
}
fun createChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels(
listOf(
NotificationChannel(
CHANNEL_PARTNER_ACTIONS,
"Partner activity",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "When your partner answers, starts a game, or unlocks something for you"
},
NotificationChannel(
CHANNEL_REMINDERS,
"Daily reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Gentle nudges for today's question and shared moments"
},
NotificationChannel(
CHANNEL_GAMES,
"Games",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "When your partner starts or completes a game round"
}
)
)
}
}

View File

@ -100,6 +100,17 @@ fun HomeScreen(
onInvite = { onNavigate(AppRoute.CREATE_INVITE) },
onReminder = viewModel::sendGentleReminder,
onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } },
onPendingAction = { card ->
when (card.priority) {
1 -> state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) }
2 -> onNavigate(AppRoute.DAILY_QUESTION)
3 -> onNavigate(AppRoute.PLAY)
4 -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
5 -> onNavigate(AppRoute.DATE_MATCHES)
6 -> onNavigate(AppRoute.MEMORY_LANE)
else -> {}
}
},
onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } },
onRefresh = viewModel::loadHome
)
@ -110,6 +121,7 @@ data class HomeCallbacks(
val onReminder: () -> Unit,
val onReveal: () -> Unit,
val onFollowUp: () -> Unit,
val onPendingAction: (PendingActionCard) -> Unit,
val onPacks: () -> Unit,
val onCategory: (String) -> Unit,
val onHistory: () -> Unit,
@ -125,9 +137,11 @@ private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
HomeActionTarget.AnswerHistory -> onHistory()
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
HomeActionTarget.Settings -> onSettings()
else -> {
// Batch 4 streak changes do not add new action targets.
}
HomeActionTarget.AnswerReveal -> onReveal()
HomeActionTarget.Game -> onPacks()
HomeActionTarget.Challenge -> onPacks()
HomeActionTarget.DatePlan -> onPacks()
HomeActionTarget.MemoryCapsule -> onPacks()
}
}
@ -144,6 +158,7 @@ private fun HomeContent(
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
onPendingAction: (PendingActionCard) -> Unit,
onRefresh: () -> Unit
) {
val callbacks = remember(
@ -734,6 +749,88 @@ private fun CategoryMiniCard(
}
}
@Composable
private fun WaitingForYouSection(
actions: List<PendingActionCard>,
onAction: (PendingActionCard) -> Unit
) {
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 ->
PendingActionCardView(card = card, onClick = { onAction(card) })
}
}
}
@Composable
private fun PendingActionCardView(
card: PendingActionCard,
onClick: () -> Unit
) {
val colors = HomeActionTone.Pending.actionColors()
CloserClickableCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = MaterialTheme.colorScheme.surface,
elevation = CloserElevations.Card
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.background(colors.soft, RoundedCornerShape(CloserRadii.Button)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = card.title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
card.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Open",
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
}
}
@Composable
private fun LoadingHomeCard() {
LoadingState(message = "Opening your dashboard")
@ -809,6 +906,7 @@ fun HomeScreenPreview() {
onHistory = {},
onSettings = {},
onInvite = {},
onPendingAction = {},
onRefresh = {}
)
}