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:
parent
978698da95
commit
deab0fd0c3
|
|
@ -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"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,6 +100,17 @@ fun HomeScreen(
|
||||||
onInvite = { onNavigate(AppRoute.CREATE_INVITE) },
|
onInvite = { onNavigate(AppRoute.CREATE_INVITE) },
|
||||||
onReminder = viewModel::sendGentleReminder,
|
onReminder = viewModel::sendGentleReminder,
|
||||||
onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } },
|
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)) } },
|
onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } },
|
||||||
onRefresh = viewModel::loadHome
|
onRefresh = viewModel::loadHome
|
||||||
)
|
)
|
||||||
|
|
@ -110,6 +121,7 @@ data class HomeCallbacks(
|
||||||
val onReminder: () -> Unit,
|
val onReminder: () -> Unit,
|
||||||
val onReveal: () -> Unit,
|
val onReveal: () -> Unit,
|
||||||
val onFollowUp: () -> Unit,
|
val onFollowUp: () -> Unit,
|
||||||
|
val onPendingAction: (PendingActionCard) -> Unit,
|
||||||
val onPacks: () -> Unit,
|
val onPacks: () -> Unit,
|
||||||
val onCategory: (String) -> Unit,
|
val onCategory: (String) -> Unit,
|
||||||
val onHistory: () -> Unit,
|
val onHistory: () -> Unit,
|
||||||
|
|
@ -125,9 +137,11 @@ private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
|
||||||
HomeActionTarget.AnswerHistory -> onHistory()
|
HomeActionTarget.AnswerHistory -> onHistory()
|
||||||
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
|
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
|
||||||
HomeActionTarget.Settings -> onSettings()
|
HomeActionTarget.Settings -> onSettings()
|
||||||
else -> {
|
HomeActionTarget.AnswerReveal -> onReveal()
|
||||||
// Batch 4 streak changes do not add new action targets.
|
HomeActionTarget.Game -> onPacks()
|
||||||
}
|
HomeActionTarget.Challenge -> onPacks()
|
||||||
|
HomeActionTarget.DatePlan -> onPacks()
|
||||||
|
HomeActionTarget.MemoryCapsule -> onPacks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +158,7 @@ private fun HomeContent(
|
||||||
onReminder: () -> Unit,
|
onReminder: () -> Unit,
|
||||||
onReveal: () -> Unit,
|
onReveal: () -> Unit,
|
||||||
onFollowUp: () -> Unit,
|
onFollowUp: () -> Unit,
|
||||||
|
onPendingAction: (PendingActionCard) -> Unit,
|
||||||
onRefresh: () -> Unit
|
onRefresh: () -> Unit
|
||||||
) {
|
) {
|
||||||
val callbacks = remember(
|
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
|
@Composable
|
||||||
private fun LoadingHomeCard() {
|
private fun LoadingHomeCard() {
|
||||||
LoadingState(message = "Opening your dashboard")
|
LoadingState(message = "Opening your dashboard")
|
||||||
|
|
@ -809,6 +906,7 @@ fun HomeScreenPreview() {
|
||||||
onHistory = {},
|
onHistory = {},
|
||||||
onSettings = {},
|
onSettings = {},
|
||||||
onInvite = {},
|
onInvite = {},
|
||||||
|
onPendingAction = {},
|
||||||
onRefresh = {}
|
onRefresh = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue