feat: add partner-trigger notifications with rate limits and quiet hours (batch v1.0.4)
- PartnerNotificationManager: 6 notification types, static copy only - NotificationRateLimiter: max 2 partner/day, 1 reminder/day, 4/week - QuietHoursManager: default 10pm-8am, user-configurable - NotificationChannelSetup: partner-actions channel (HIGH importance) - PartnerNotificationScheduler: routes FCM events through manager - AppMessagingService: safe static copy, drops unknown types - Deep link navigation for all notification targets - 3 test files covering quiet hours, rate limits, notification types
This commit is contained in:
parent
deab0fd0c3
commit
a07d92be53
|
|
@ -4,6 +4,7 @@ import android.app.Application
|
||||||
import app.closer.core.firebase.FirebaseInitializer
|
import app.closer.core.firebase.FirebaseInitializer
|
||||||
import app.closer.data.repository.ActivityProvider
|
import app.closer.data.repository.ActivityProvider
|
||||||
import app.closer.domain.security.DeviceIntegrityChecker
|
import app.closer.domain.security.DeviceIntegrityChecker
|
||||||
|
import app.closer.notifications.NotificationChannelSetup
|
||||||
import com.google.crypto.tink.aead.AeadConfig
|
import com.google.crypto.tink.aead.AeadConfig
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -24,6 +25,7 @@ class CloserApp : Application() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AeadConfig.register()
|
AeadConfig.register()
|
||||||
ActivityProvider.register(this)
|
ActivityProvider.register(this)
|
||||||
|
NotificationChannelSetup.createChannels(applicationContext)
|
||||||
firebaseInitializer.initialize()
|
firebaseInitializer.initialize()
|
||||||
appScope.launch { deviceIntegrityChecker.runCheck() }
|
appScope.launch { deviceIntegrityChecker.runCheck() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer
|
package app.closer
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
|
@ -51,4 +52,9 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
|
import androidx.navigation.navDeepLink
|
||||||
import app.closer.ui.auth.ForgotPasswordScreen
|
import app.closer.ui.auth.ForgotPasswordScreen
|
||||||
import app.closer.ui.answers.AnswerHistoryScreen
|
import app.closer.ui.answers.AnswerHistoryScreen
|
||||||
import app.closer.ui.answers.AnswerRevealScreen
|
import app.closer.ui.answers.AnswerRevealScreen
|
||||||
|
|
@ -180,18 +181,27 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
composable(route = AppRoute.HOME) {
|
composable(
|
||||||
|
route = AppRoute.HOME,
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/home" })
|
||||||
|
) {
|
||||||
HomeScreen(onNavigate = navigateRoute)
|
HomeScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.PARTNER_HOME) {
|
composable(route = AppRoute.PARTNER_HOME) {
|
||||||
PartnerHomeScreen(onNavigate = navigateRoute)
|
PartnerHomeScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.PLAY) {
|
composable(
|
||||||
|
route = AppRoute.PLAY,
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/play" })
|
||||||
|
) {
|
||||||
PlayHubScreen(onNavigate = navigateRoute)
|
PlayHubScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daily Question
|
// Daily Question
|
||||||
composable(route = AppRoute.DAILY_QUESTION) {
|
composable(
|
||||||
|
route = AppRoute.DAILY_QUESTION,
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/daily_question" })
|
||||||
|
) {
|
||||||
DailyQuestionScreen(onNavigate = navigateRoute)
|
DailyQuestionScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.QUESTION_PACKS) {
|
composable(route = AppRoute.QUESTION_PACKS) {
|
||||||
|
|
@ -241,14 +251,18 @@ fun AppNavigation(
|
||||||
// Answers
|
// Answers
|
||||||
composable(
|
composable(
|
||||||
route = AppRoute.ANSWER_REVEAL,
|
route = AppRoute.ANSWER_REVEAL,
|
||||||
arguments = listOf(navArgument("questionId") { type = NavType.StringType })
|
arguments = listOf(navArgument("questionId") { type = NavType.StringType }),
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/answer_reveal/{questionId}" })
|
||||||
) {
|
) {
|
||||||
AnswerRevealScreen(
|
AnswerRevealScreen(
|
||||||
questionId = it.arguments?.getString("questionId") ?: "",
|
questionId = it.arguments?.getString("questionId") ?: "",
|
||||||
onNavigate = navigateRoute
|
onNavigate = navigateRoute
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.ANSWER_HISTORY) {
|
composable(
|
||||||
|
route = AppRoute.ANSWER_HISTORY,
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/answer_history" })
|
||||||
|
) {
|
||||||
AnswerHistoryScreen(onNavigate = navigateRoute)
|
AnswerHistoryScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,10 +380,16 @@ fun AppNavigation(
|
||||||
composable(route = AppRoute.DESIRE_SYNC) {
|
composable(route = AppRoute.DESIRE_SYNC) {
|
||||||
DesireSyncScreen(onNavigate = navigateRoute)
|
DesireSyncScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.CONNECTION_CHALLENGES) {
|
composable(
|
||||||
|
route = AppRoute.CONNECTION_CHALLENGES,
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/connection_challenges" })
|
||||||
|
) {
|
||||||
ConnectionChallengesScreen(onNavigate = navigateRoute)
|
ConnectionChallengesScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.MEMORY_LANE) {
|
composable(
|
||||||
|
route = AppRoute.MEMORY_LANE,
|
||||||
|
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/memory_lane" })
|
||||||
|
) {
|
||||||
MemoryLaneScreen(onNavigate = navigateRoute)
|
MemoryLaneScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
composable(route = AppRoute.WAITING_FOR_PARTNER) {
|
composable(route = AppRoute.WAITING_FOR_PARTNER) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
package app.closer.core.notifications
|
package app.closer.core.notifications
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.repository.UserRepository
|
import app.closer.domain.repository.UserRepository
|
||||||
|
import app.closer.notifications.NotificationChannelSetup
|
||||||
|
import app.closer.notifications.PartnerNotificationManager
|
||||||
|
import app.closer.notifications.PartnerNotificationPayload
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
@ -11,9 +12,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Calendar
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
|
@ -21,13 +20,13 @@ class AppMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
@Inject lateinit var authRepository: AuthRepository
|
@Inject lateinit var authRepository: AuthRepository
|
||||||
@Inject lateinit var userRepository: UserRepository
|
@Inject lateinit var userRepository: UserRepository
|
||||||
|
@Inject lateinit var partnerNotificationManager: PartnerNotificationManager
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val Context.dataStore by preferencesDataStore(name = "settings")
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
NotificationHelper.createChannels(this)
|
NotificationChannelSetup.createChannels(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
|
@ -54,86 +53,22 @@ class AppMessagingService : FirebaseMessagingService() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
val coupleId = message.data["couple_id"] ?: return
|
||||||
|
val type = message.data["type"] ?: return
|
||||||
|
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val prefs = dataStore.data.first()
|
partnerNotificationManager.handleRemote(
|
||||||
val quietHoursEnabled = prefs[androidx.datastore.preferences.core.booleanPreferencesKey("quiet_hours")] ?: false
|
type = type,
|
||||||
val startHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_hour")] ?: 22
|
coupleId = coupleId,
|
||||||
val startMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_minute")] ?: 0
|
payload = PartnerNotificationPayload(
|
||||||
val endHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_hour")] ?: 8
|
questionId = message.data["question_id"],
|
||||||
val endMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_minute")] ?: 0
|
gameSessionId = message.data["game_session_id"],
|
||||||
|
capsuleId = message.data["capsule_id"],
|
||||||
if (quietHoursEnabled && isInQuietHours(startHour, startMinute, endHour, endMinute)) {
|
challengeId = message.data["challenge_id"]
|
||||||
return@runCatching
|
)
|
||||||
}
|
|
||||||
|
|
||||||
val type = message.data["type"] ?: "general"
|
|
||||||
val title = message.notification?.title
|
|
||||||
?: resolveTitle(type)
|
|
||||||
?: message.data["title"]
|
|
||||||
?: return@runCatching
|
|
||||||
val body = message.notification?.body
|
|
||||||
?: resolveBody(type)
|
|
||||||
?: message.data["body"]
|
|
||||||
?: return@runCatching
|
|
||||||
|
|
||||||
val channelId = when (type) {
|
|
||||||
"partner_answered" -> NotificationHelper.CHANNEL_PARTNER
|
|
||||||
"partner_left" -> NotificationHelper.CHANNEL_PARTNER
|
|
||||||
"partner_started_game", "partner_finished_game", "partner_waiting" -> NotificationHelper.CHANNEL_PARTNER
|
|
||||||
"memory_capsule_unlocked", "challenge_day_ready" -> NotificationHelper.CHANNEL_REMINDERS
|
|
||||||
"daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS
|
|
||||||
else -> NotificationHelper.CHANNEL_REMINDERS
|
|
||||||
}
|
|
||||||
|
|
||||||
NotificationHelper.show(
|
|
||||||
context = this@AppMessagingService,
|
|
||||||
id = System.currentTimeMillis().toInt(),
|
|
||||||
channelId = channelId,
|
|
||||||
title = title,
|
|
||||||
body = body
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTitle(type: String): String? = when (type) {
|
|
||||||
"daily_question" -> "Today's question is here."
|
|
||||||
"partner_answered" -> "Your partner answered."
|
|
||||||
"partner_left" -> "You've been unlinked."
|
|
||||||
"streak" -> "A question is waiting for you."
|
|
||||||
"partner_started_game" -> "Your partner started a game."
|
|
||||||
"partner_finished_game" -> "Your partner finished the round."
|
|
||||||
"partner_waiting" -> "Your partner is waiting."
|
|
||||||
"memory_capsule_unlocked" -> "Your capsule just opened."
|
|
||||||
"challenge_day_ready" -> "A new connection moment is ready."
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveBody(type: String): String? = when (type) {
|
|
||||||
"daily_question" -> "Take a moment to answer. Your partner's waiting too."
|
|
||||||
"partner_answered" -> "See what they shared — then reveal when you're ready."
|
|
||||||
"partner_left" -> "Your shared space has been closed. Create a new invite whenever you're ready."
|
|
||||||
"streak" -> "Answer today's question to keep your shared rhythm going."
|
|
||||||
"partner_started_game" -> "They're in — tap to join them."
|
|
||||||
"partner_finished_game" -> "Time to compare notes. See your results together."
|
|
||||||
"partner_waiting" -> "They finished their side. Whenever you're ready, complete yours."
|
|
||||||
"memory_capsule_unlocked" -> "Something you sealed together is ready to open."
|
|
||||||
"challenge_day_ready" -> "Your next connection challenge is here — open it together."
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isInQuietHours(startHour: Int, startMinute: Int, endHour: Int, endMinute: Int): Boolean {
|
|
||||||
val now = Calendar.getInstance()
|
|
||||||
val currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
|
|
||||||
val start = startHour * 60 + startMinute
|
|
||||||
val end = endHour * 60 + endMinute
|
|
||||||
|
|
||||||
return if (start <= end) {
|
|
||||||
currentMinutes in start..end
|
|
||||||
} else {
|
|
||||||
// Quiet window crosses midnight (e.g. 22:00 → 08:00)
|
|
||||||
currentMinutes >= start || currentMinutes <= end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
package app.closer.di
|
package app.closer.di
|
||||||
|
|
||||||
import app.closer.core.notifications.NotificationPermissionHelper
|
import android.content.Context
|
||||||
import app.closer.core.notifications.TokenRegistrar
|
import app.closer.domain.repository.SettingsRepository
|
||||||
|
import app.closer.notifications.NotificationRateLimiter
|
||||||
|
import app.closer.notifications.PartnerNotificationManager
|
||||||
|
import app.closer.notifications.QuietHoursManager
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -16,4 +20,28 @@ object NotificationModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideFirebaseMessaging(): FirebaseMessaging = FirebaseMessaging.getInstance()
|
fun provideFirebaseMessaging(): FirebaseMessaging = FirebaseMessaging.getInstance()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideQuietHoursManager(): QuietHoursManager = QuietHoursManager()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideNotificationRateLimiter(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
): NotificationRateLimiter = NotificationRateLimiter(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePartnerNotificationManager(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
settingsRepository: SettingsRepository,
|
||||||
|
quietHoursManager: QuietHoursManager,
|
||||||
|
rateLimiter: NotificationRateLimiter
|
||||||
|
): PartnerNotificationManager = PartnerNotificationManager(
|
||||||
|
context,
|
||||||
|
settingsRepository,
|
||||||
|
quietHoursManager,
|
||||||
|
rateLimiter
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
package app.closer.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted rate limiter for partner-trigger and reminder notifications.
|
||||||
|
*
|
||||||
|
* Limits:
|
||||||
|
* - 2 partner-trigger notifications per day
|
||||||
|
* - 1 reminder notification per day
|
||||||
|
* - 4 total notifications per week
|
||||||
|
*
|
||||||
|
* Counts are stored in [SharedPreferences] and reset when a new day or week starts.
|
||||||
|
*/
|
||||||
|
class NotificationRateLimiter(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
enum class Type { PARTNER_TRIGGER, REMINDER }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "notification_rate_limits"
|
||||||
|
private const val KEY_DAY_START = "day_start"
|
||||||
|
private const val KEY_WEEK_START = "week_start"
|
||||||
|
private const val KEY_PARTNER_COUNT = "partner_count"
|
||||||
|
private const val KEY_REMINDER_COUNT = "reminder_count"
|
||||||
|
private const val KEY_TOTAL_COUNT = "total_count"
|
||||||
|
|
||||||
|
const val MAX_PARTNER_PER_DAY = 2
|
||||||
|
const val MAX_REMINDER_PER_DAY = 1
|
||||||
|
const val MAX_TOTAL_PER_WEEK = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a notification of [type] can be shown right now without
|
||||||
|
* breaking daily or weekly limits.
|
||||||
|
*
|
||||||
|
* Calling this method may reset day/week counters if a new window has started.
|
||||||
|
*/
|
||||||
|
fun canSend(type: Type): Boolean {
|
||||||
|
resetIfNewWindows()
|
||||||
|
if (isTotalOverLimit()) return false
|
||||||
|
|
||||||
|
return when (type) {
|
||||||
|
Type.PARTNER_TRIGGER -> prefs.getInt(KEY_PARTNER_COUNT, 0) < MAX_PARTNER_PER_DAY
|
||||||
|
Type.REMINDER -> prefs.getInt(KEY_REMINDER_COUNT, 0) < MAX_REMINDER_PER_DAY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that a notification of [type] was shown.
|
||||||
|
*
|
||||||
|
* Must only be called after [canSend] returned true.
|
||||||
|
*/
|
||||||
|
fun record(type: Type) {
|
||||||
|
resetIfNewWindows()
|
||||||
|
prefs.edit().apply {
|
||||||
|
putInt(KEY_TOTAL_COUNT, prefs.getInt(KEY_TOTAL_COUNT, 0) + 1)
|
||||||
|
when (type) {
|
||||||
|
Type.PARTNER_TRIGGER -> putInt(
|
||||||
|
KEY_PARTNER_COUNT,
|
||||||
|
prefs.getInt(KEY_PARTNER_COUNT, 0) + 1
|
||||||
|
)
|
||||||
|
Type.REMINDER -> putInt(
|
||||||
|
KEY_REMINDER_COUNT,
|
||||||
|
prefs.getInt(KEY_REMINDER_COUNT, 0) + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for testing. Returns the current persisted count for [type]. */
|
||||||
|
internal fun count(type: Type): Int {
|
||||||
|
resetIfNewWindows()
|
||||||
|
return when (type) {
|
||||||
|
Type.PARTNER_TRIGGER -> prefs.getInt(KEY_PARTNER_COUNT, 0)
|
||||||
|
Type.REMINDER -> prefs.getInt(KEY_REMINDER_COUNT, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for testing. Returns the current persisted weekly total. */
|
||||||
|
internal fun totalCount(): Int {
|
||||||
|
resetIfNewWindows()
|
||||||
|
return prefs.getInt(KEY_TOTAL_COUNT, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTotalOverLimit(): Boolean =
|
||||||
|
prefs.getInt(KEY_TOTAL_COUNT, 0) >= MAX_TOTAL_PER_WEEK
|
||||||
|
|
||||||
|
private fun resetIfNewWindows() {
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
val currentDayStart = dayStartMillis(now)
|
||||||
|
val currentWeekStart = weekStartMillis(now)
|
||||||
|
val lastDayStart = prefs.getLong(KEY_DAY_START, 0)
|
||||||
|
val lastWeekStart = prefs.getLong(KEY_WEEK_START, 0)
|
||||||
|
|
||||||
|
val editor = prefs.edit()
|
||||||
|
if (currentDayStart != lastDayStart) {
|
||||||
|
editor.putLong(KEY_DAY_START, currentDayStart)
|
||||||
|
.putInt(KEY_PARTNER_COUNT, 0)
|
||||||
|
.putInt(KEY_REMINDER_COUNT, 0)
|
||||||
|
}
|
||||||
|
if (currentWeekStart != lastWeekStart) {
|
||||||
|
editor.putLong(KEY_WEEK_START, currentWeekStart)
|
||||||
|
.putInt(KEY_TOTAL_COUNT, 0)
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dayStartMillis(calendar: Calendar): Long {
|
||||||
|
return calendar.timeInMillis / TimeUnit.DAYS.toMillis(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weekStartMillis(calendar: Calendar): Long {
|
||||||
|
// Calendar.DAY_OF_WEEK: SUNDAY=1 ... SATURDAY=7.
|
||||||
|
val daysSinceSunday = (calendar.get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY).toLong()
|
||||||
|
val dayIndex = calendar.timeInMillis / TimeUnit.DAYS.toMillis(1)
|
||||||
|
return dayIndex - daysSinceSunday
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
package app.closer.notifications
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import app.closer.MainActivity
|
||||||
|
import app.closer.R
|
||||||
|
import app.closer.core.navigation.AppRoute
|
||||||
|
import app.closer.domain.repository.AppSettings
|
||||||
|
import app.closer.domain.repository.SettingsRepository
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages local and FCM-driven partner-triggered notifications.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Enforce notification opt-outs from [SettingsRepository].
|
||||||
|
* - Respect quiet hours via [QuietHoursManager].
|
||||||
|
* - Apply rate limits via [NotificationRateLimiter].
|
||||||
|
* - Collapse duplicate notifications by deriving the notification ID from
|
||||||
|
* the notification type and couple ID hash.
|
||||||
|
* - Deep link each notification to the correct screen.
|
||||||
|
*
|
||||||
|
* Security: notification titles and bodies are static. Answer text, prompt
|
||||||
|
* text, and other sensitive content are never included.
|
||||||
|
*/
|
||||||
|
class PartnerNotificationManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val quietHoursManager: QuietHoursManager,
|
||||||
|
private val rateLimiter: NotificationRateLimiter
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a partner-trigger notification if the user has not opted out,
|
||||||
|
* quiet hours are not active, and rate limits allow it.
|
||||||
|
*
|
||||||
|
* @param type The notification type (decides copy, channel, and deep link).
|
||||||
|
* @param coupleId The couple ID used for duplicate collapse.
|
||||||
|
* @param payload Optional IDs needed to build the exact deep link route.
|
||||||
|
*/
|
||||||
|
suspend fun show(
|
||||||
|
type: PartnerNotificationType,
|
||||||
|
coupleId: String,
|
||||||
|
payload: PartnerNotificationPayload = PartnerNotificationPayload()
|
||||||
|
) {
|
||||||
|
if (coupleId.isBlank()) return
|
||||||
|
|
||||||
|
val settings = settingsRepository.settings.first()
|
||||||
|
|
||||||
|
if (!isEnabled(type, settings)) return
|
||||||
|
if (quietHoursManager.isInQuietHours(settings.quietHours)) return
|
||||||
|
if (!rateLimiter.canSend(type.rateType)) return
|
||||||
|
|
||||||
|
rateLimiter.record(type.rateType)
|
||||||
|
|
||||||
|
val route = type.routeFor(payload)
|
||||||
|
val notificationId = collapseId(type, coupleId)
|
||||||
|
|
||||||
|
showNotification(notificationId, type, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a remote FCM message type to a [PartnerNotificationType] and shows it.
|
||||||
|
*
|
||||||
|
* Unknown types are ignored so the backend cannot force arbitrary notification text.
|
||||||
|
*/
|
||||||
|
suspend fun handleRemote(
|
||||||
|
type: String,
|
||||||
|
coupleId: String,
|
||||||
|
payload: PartnerNotificationPayload = PartnerNotificationPayload()
|
||||||
|
) {
|
||||||
|
val notificationType = PartnerNotificationType.fromRemoteType(type) ?: return
|
||||||
|
show(notificationType, coupleId, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEnabled(type: PartnerNotificationType, settings: AppSettings): Boolean {
|
||||||
|
return when (type.rateType) {
|
||||||
|
NotificationRateLimiter.Type.PARTNER_TRIGGER -> settings.partnerAnsweredEnabled
|
||||||
|
NotificationRateLimiter.Type.REMINDER -> settings.dailyReminderEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(id: Int, type: PartnerNotificationType, route: String) {
|
||||||
|
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return
|
||||||
|
|
||||||
|
val deepLinkUri = Uri.parse("${DEEP_LINK_SCHEME}://$DEEP_LINK_HOST/$route")
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = deepLinkUri
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, type.channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle(type.title)
|
||||||
|
.setContentText(type.body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(id, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collapseId(type: PartnerNotificationType, coupleId: String): Int {
|
||||||
|
// Same type + same couple always collapses to the same ID,
|
||||||
|
// preventing duplicate notifications in the shade.
|
||||||
|
val hash = type.name.hashCode() * 31 + coupleId.hashCode()
|
||||||
|
return (hash and 0x7FFFFFFF) % ID_MODULO
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEEP_LINK_SCHEME = "closer"
|
||||||
|
private const val DEEP_LINK_HOST = "closer.app"
|
||||||
|
private const val ID_MODULO = 100_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The supported partner-trigger notification types.
|
||||||
|
*
|
||||||
|
* Titles and bodies are fixed strings; no user-generated content is ever shown.
|
||||||
|
*/
|
||||||
|
enum class PartnerNotificationType(
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val channelId: String,
|
||||||
|
val rateType: NotificationRateLimiter.Type
|
||||||
|
) {
|
||||||
|
PARTNER_ANSWERED(
|
||||||
|
title = "Your partner answered.",
|
||||||
|
body = "Your turn to unlock the reveal.",
|
||||||
|
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
|
||||||
|
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
|
||||||
|
),
|
||||||
|
REVEAL_READY(
|
||||||
|
title = "Your reveal is ready.",
|
||||||
|
body = "Open it when you're both ready.",
|
||||||
|
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
|
||||||
|
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
|
||||||
|
),
|
||||||
|
PARTNER_STARTED_GAME(
|
||||||
|
title = "Your partner started a game for the two of you.",
|
||||||
|
body = "Tap to join them.",
|
||||||
|
channelId = NotificationChannelSetup.CHANNEL_GAMES,
|
||||||
|
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
|
||||||
|
),
|
||||||
|
PARTNER_COMPLETED_PART(
|
||||||
|
title = "Your partner finished their part.",
|
||||||
|
body = "Open yours when you're ready.",
|
||||||
|
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
|
||||||
|
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
|
||||||
|
),
|
||||||
|
CHALLENGE_WAITING(
|
||||||
|
title = "Tonight's small challenge is waiting.",
|
||||||
|
body = "A little shared moment is ready.",
|
||||||
|
channelId = NotificationChannelSetup.CHANNEL_REMINDERS,
|
||||||
|
rateType = NotificationRateLimiter.Type.REMINDER
|
||||||
|
),
|
||||||
|
CAPSULE_UNLOCKED(
|
||||||
|
title = "A memory capsule just unlocked.",
|
||||||
|
body = "Something you sealed together is ready to open.",
|
||||||
|
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
|
||||||
|
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the deep link route for this notification type.
|
||||||
|
*/
|
||||||
|
fun routeFor(payload: PartnerNotificationPayload): String = when (this) {
|
||||||
|
PARTNER_ANSWERED -> AppRoute.DAILY_QUESTION
|
||||||
|
REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY
|
||||||
|
PARTNER_STARTED_GAME -> AppRoute.PLAY
|
||||||
|
PARTNER_COMPLETED_PART -> AppRoute.PLAY
|
||||||
|
CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES
|
||||||
|
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Maps backend FCM message types to local notification types.
|
||||||
|
* Returns null for unknown types so arbitrary backend text cannot be shown.
|
||||||
|
*/
|
||||||
|
fun fromRemoteType(type: String): PartnerNotificationType? = when (type) {
|
||||||
|
"partner_answered" -> PARTNER_ANSWERED
|
||||||
|
"reveal_ready" -> REVEAL_READY
|
||||||
|
"partner_started_game" -> PARTNER_STARTED_GAME
|
||||||
|
"partner_completed_part" -> PARTNER_COMPLETED_PART
|
||||||
|
"challenge_waiting" -> CHALLENGE_WAITING
|
||||||
|
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional IDs used to build exact deep link routes.
|
||||||
|
*
|
||||||
|
* No answer text, prompt text, or decrypted content is included.
|
||||||
|
*/
|
||||||
|
data class PartnerNotificationPayload(
|
||||||
|
val questionId: String? = null,
|
||||||
|
val gameSessionId: String? = null,
|
||||||
|
val capsuleId: String? = null,
|
||||||
|
val challengeId: String? = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package app.closer.notifications
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules partner-trigger notifications in response to partner actions.
|
||||||
|
*
|
||||||
|
* This class is intentionally synchronous: it validates quiet hours, opt-outs,
|
||||||
|
* and rate limits at the moment the event is received. Background deferral is
|
||||||
|
* handled by the backend FCM delivery schedule and [PartnerNotificationManager].
|
||||||
|
*/
|
||||||
|
class PartnerNotificationScheduler @Inject constructor(
|
||||||
|
private val notificationManager: PartnerNotificationManager
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The partner answered today's question; prompt the user to answer too.
|
||||||
|
*/
|
||||||
|
suspend fun onPartnerAnswered(coupleId: String, questionId: String? = null) {
|
||||||
|
notificationManager.show(
|
||||||
|
PartnerNotificationType.PARTNER_ANSWERED,
|
||||||
|
coupleId,
|
||||||
|
PartnerNotificationPayload(questionId = questionId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both partners have answered and the reveal is ready.
|
||||||
|
*/
|
||||||
|
suspend fun onRevealReady(coupleId: String, questionId: String) {
|
||||||
|
notificationManager.show(
|
||||||
|
PartnerNotificationType.REVEAL_READY,
|
||||||
|
coupleId,
|
||||||
|
PartnerNotificationPayload(questionId = questionId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The partner started a game for the couple.
|
||||||
|
*/
|
||||||
|
suspend fun onPartnerStartedGame(coupleId: String, gameSessionId: String? = null) {
|
||||||
|
notificationManager.show(
|
||||||
|
PartnerNotificationType.PARTNER_STARTED_GAME,
|
||||||
|
coupleId,
|
||||||
|
PartnerNotificationPayload(gameSessionId = gameSessionId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The partner completed their side of a two-player game.
|
||||||
|
*/
|
||||||
|
suspend fun onPartnerCompletedPart(coupleId: String, gameSessionId: String? = null) {
|
||||||
|
notificationManager.show(
|
||||||
|
PartnerNotificationType.PARTNER_COMPLETED_PART,
|
||||||
|
coupleId,
|
||||||
|
PartnerNotificationPayload(gameSessionId = gameSessionId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A daily connection challenge is waiting for the couple.
|
||||||
|
*/
|
||||||
|
suspend fun onChallengeWaiting(coupleId: String, challengeId: String? = null) {
|
||||||
|
notificationManager.show(
|
||||||
|
PartnerNotificationType.CHALLENGE_WAITING,
|
||||||
|
coupleId,
|
||||||
|
PartnerNotificationPayload(challengeId = challengeId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A previously sealed memory capsule reached its unlock date.
|
||||||
|
*/
|
||||||
|
suspend fun onCapsuleUnlocked(coupleId: String, capsuleId: String? = null) {
|
||||||
|
notificationManager.show(
|
||||||
|
PartnerNotificationType.CAPSULE_UNLOCKED,
|
||||||
|
coupleId,
|
||||||
|
PartnerNotificationPayload(capsuleId = capsuleId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package app.closer.notifications
|
||||||
|
|
||||||
|
import app.closer.core.notifications.QuietHours
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a notification should be suppressed because the current time
|
||||||
|
* falls inside the user's quiet hours window.
|
||||||
|
*
|
||||||
|
* Default quiet hours are 22:00 → 08:00 and are user-configurable via
|
||||||
|
* [app.closer.domain.repository.SettingsRepository].
|
||||||
|
*/
|
||||||
|
class QuietHoursManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if [now] is inside the configured quiet window.
|
||||||
|
*
|
||||||
|
* Windows that cross midnight (e.g. 22:00 → 08:00) are handled correctly.
|
||||||
|
*/
|
||||||
|
fun isInQuietHours(
|
||||||
|
quietHours: QuietHours,
|
||||||
|
now: Calendar = Calendar.getInstance()
|
||||||
|
): Boolean {
|
||||||
|
if (!quietHours.enabled) return false
|
||||||
|
|
||||||
|
val currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
|
||||||
|
val startMinutes = quietHours.startHour * 60 + quietHours.startMinute
|
||||||
|
val endMinutes = quietHours.endHour * 60 + quietHours.endMinute
|
||||||
|
|
||||||
|
return if (startMinutes <= endMinutes) {
|
||||||
|
currentMinutes in startMinutes..endMinutes
|
||||||
|
} else {
|
||||||
|
currentMinutes >= startMinutes || currentMinutes <= endMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
package app.closer.ui.home
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure logic that decides what to show on the home screen based on user state.
|
||||||
|
*
|
||||||
|
* The engine has no Android dependencies and no side effects. It takes a snapshot
|
||||||
|
* of flags and emits a prioritized list of actions: one primary CTA and up to three
|
||||||
|
* secondary cards.
|
||||||
|
*
|
||||||
|
* Priority order (highest to lowest):
|
||||||
|
* 1. Critical privacy or account action
|
||||||
|
* 2. Pairing needed
|
||||||
|
* 3. Encryption unlock needed
|
||||||
|
* 4. Reveal ready
|
||||||
|
* 5. Partner answered, user pending
|
||||||
|
* 6. Game waiting
|
||||||
|
* 7. Challenge waiting
|
||||||
|
* 8. Daily question unanswered
|
||||||
|
* 9. Weekly recap ready
|
||||||
|
* 10. Capsule unlocked
|
||||||
|
* 11. Date reminder
|
||||||
|
* 12. Suggested pack
|
||||||
|
* 13. Explore games
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Show one primary CTA.
|
||||||
|
* - Show up to 3 secondary cards.
|
||||||
|
* - Do not show paywall before core value is experienced.
|
||||||
|
* - Do not show generic content above partner-triggered actions.
|
||||||
|
* - Partner-triggered items outrank generic browse items.
|
||||||
|
*/
|
||||||
|
object HomePriorityEngine {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input snapshot for the engine. All fields are simple values so callers can
|
||||||
|
* build it from any source (ViewModel, tests, previews) without dependencies.
|
||||||
|
*/
|
||||||
|
data class Input(
|
||||||
|
val needsCriticalAction: Boolean = false,
|
||||||
|
val isPaired: Boolean = false,
|
||||||
|
val needsEncryptionUnlock: Boolean = false,
|
||||||
|
val revealReady: Boolean = false,
|
||||||
|
val partnerAnsweredUserPending: Boolean = false,
|
||||||
|
val gameWaiting: Boolean = false,
|
||||||
|
val challengeWaiting: Boolean = false,
|
||||||
|
val dailyQuestionUnanswered: Boolean = false,
|
||||||
|
val weeklyRecapReady: Boolean = false,
|
||||||
|
val capsuleUnlocked: Boolean = false,
|
||||||
|
val dateReminder: Boolean = false,
|
||||||
|
val suggestedPackAvailable: Boolean = false,
|
||||||
|
val exploreGamesAvailable: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Priority {
|
||||||
|
CRITICAL_ACTION,
|
||||||
|
PAIRING_NEEDED,
|
||||||
|
ENCRYPTION_UNLOCK_NEEDED,
|
||||||
|
REVEAL_READY,
|
||||||
|
PARTNER_ANSWERED_USER_PENDING,
|
||||||
|
GAME_WAITING,
|
||||||
|
CHALLENGE_WAITING,
|
||||||
|
DAILY_QUESTION_UNANSWERED,
|
||||||
|
WEEKLY_RECAP_READY,
|
||||||
|
CAPSULE_UNLOCKED,
|
||||||
|
DATE_REMINDER,
|
||||||
|
SUGGESTED_PACK,
|
||||||
|
EXPLORE_GAMES
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single prioritized action emitted by the engine.
|
||||||
|
*
|
||||||
|
* @param priority The engine priority. Lower ordinal = higher rank.
|
||||||
|
* @param isPrimary True when this item should be the main CTA.
|
||||||
|
*/
|
||||||
|
data class PrioritizedAction(
|
||||||
|
val priority: Priority,
|
||||||
|
val isPrimary: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engine output: exactly one primary action and up to [maxSecondary] secondary cards.
|
||||||
|
*/
|
||||||
|
data class Output(
|
||||||
|
val primary: PrioritizedAction?,
|
||||||
|
val secondary: List<PrioritizedAction>
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MAX_SECONDARY = 3
|
||||||
|
|
||||||
|
private val priorityOrder = Priority.entries.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the home screen priority from the given [input].
|
||||||
|
*
|
||||||
|
* @return An [Output] containing one primary action and up to 3 secondary actions.
|
||||||
|
*/
|
||||||
|
fun compute(input: Input): Output {
|
||||||
|
val active = priorityOrder.filter { it.isActive(input) }
|
||||||
|
|
||||||
|
if (active.isEmpty()) {
|
||||||
|
return Output(primary = null, secondary = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val primary = active.first()
|
||||||
|
val secondary = active
|
||||||
|
.drop(1)
|
||||||
|
.filter { it.isPartnerTriggered() || it.isValueAction() }
|
||||||
|
.take(MAX_SECONDARY)
|
||||||
|
|
||||||
|
return Output(
|
||||||
|
primary = PrioritizedAction(priority = primary, isPrimary = true),
|
||||||
|
secondary = secondary.map { PrioritizedAction(priority = it, isPrimary = false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience overload that returns the primary priority directly, or null
|
||||||
|
* when nothing is active.
|
||||||
|
*/
|
||||||
|
fun primaryPriority(input: Input): Priority? = compute(input).primary?.priority
|
||||||
|
|
||||||
|
private fun Priority.isActive(input: Input): Boolean = when (this) {
|
||||||
|
Priority.CRITICAL_ACTION -> input.needsCriticalAction
|
||||||
|
Priority.PAIRING_NEEDED -> !input.isPaired
|
||||||
|
Priority.ENCRYPTION_UNLOCK_NEEDED -> input.needsEncryptionUnlock
|
||||||
|
Priority.REVEAL_READY -> input.revealReady
|
||||||
|
Priority.PARTNER_ANSWERED_USER_PENDING -> input.partnerAnsweredUserPending
|
||||||
|
Priority.GAME_WAITING -> input.gameWaiting
|
||||||
|
Priority.CHALLENGE_WAITING -> input.challengeWaiting
|
||||||
|
Priority.DAILY_QUESTION_UNANSWERED -> input.dailyQuestionUnanswered
|
||||||
|
Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady
|
||||||
|
Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked
|
||||||
|
Priority.DATE_REMINDER -> input.dateReminder
|
||||||
|
Priority.SUGGESTED_PACK -> input.suggestedPackAvailable
|
||||||
|
Priority.EXPLORE_GAMES -> input.exploreGamesAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner-triggered actions are items that should appear before generic browse
|
||||||
|
* content because they respond to something the partner did.
|
||||||
|
*/
|
||||||
|
private fun Priority.isPartnerTriggered(): Boolean = when (this) {
|
||||||
|
Priority.REVEAL_READY,
|
||||||
|
Priority.PARTNER_ANSWERED_USER_PENDING,
|
||||||
|
Priority.GAME_WAITING,
|
||||||
|
Priority.CHALLENGE_WAITING,
|
||||||
|
Priority.CAPSULE_UNLOCKED -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value actions are non-generic content that keeps the daily ritual moving.
|
||||||
|
*/
|
||||||
|
private fun Priority.isValueAction(): Boolean = when (this) {
|
||||||
|
Priority.DAILY_QUESTION_UNANSWERED,
|
||||||
|
Priority.WEEKLY_RECAP_READY,
|
||||||
|
Priority.DATE_REMINDER -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,7 +130,7 @@ data class HomeCallbacks(
|
||||||
val onRefresh: () -> Unit
|
val onRefresh: () -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
|
private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAction) -> Unit = { action ->
|
||||||
when (action.target) {
|
when (action.target) {
|
||||||
HomeActionTarget.InvitePartner -> onInvite()
|
HomeActionTarget.InvitePartner -> onInvite()
|
||||||
HomeActionTarget.DailyQuestion -> onDailyQuestion()
|
HomeActionTarget.DailyQuestion -> onDailyQuestion()
|
||||||
|
|
@ -138,10 +138,10 @@ private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
|
||||||
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
|
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
|
||||||
HomeActionTarget.Settings -> onSettings()
|
HomeActionTarget.Settings -> onSettings()
|
||||||
HomeActionTarget.AnswerReveal -> onReveal()
|
HomeActionTarget.AnswerReveal -> onReveal()
|
||||||
HomeActionTarget.Game -> onPacks()
|
HomeActionTarget.Game -> onNavigate(AppRoute.PLAY)
|
||||||
HomeActionTarget.Challenge -> onPacks()
|
HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
|
||||||
HomeActionTarget.DatePlan -> onPacks()
|
HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES)
|
||||||
HomeActionTarget.MemoryCapsule -> onPacks()
|
HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,7 +179,7 @@ private fun HomeContent(
|
||||||
onRefresh = onRefresh
|
onRefresh = onRefresh
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val onActionSelected = callbacks.toActionHandler()
|
val onActionSelected = callbacks.toActionHandler { route -> onNavigate(route) }
|
||||||
val onPendingActionSelected: (PendingActionCard) -> Unit = { card ->
|
val onPendingActionSelected: (PendingActionCard) -> Unit = { card ->
|
||||||
card.action()
|
card.action()
|
||||||
callbacks.onPendingAction(card)
|
callbacks.onPendingAction(card)
|
||||||
|
|
@ -241,7 +241,18 @@ private fun HomeContent(
|
||||||
onAction = onActionSelected
|
onAction = onActionSelected
|
||||||
)
|
)
|
||||||
|
|
||||||
MomentCueCard()
|
if (state.primaryAction == null && state.secondaryActions.isEmpty()) {
|
||||||
|
EmptyHomeContent(
|
||||||
|
dailyQuestion = state.dailyQuestion,
|
||||||
|
onDailyQuestion = callbacks.onDailyQuestion,
|
||||||
|
onPacks = callbacks.onPacks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.secondaryActions.any { it.target == HomeActionTarget.QuestionPacks } ||
|
||||||
|
state.primaryAction?.target != HomeActionTarget.QuestionPacks) {
|
||||||
|
MomentCueCard()
|
||||||
|
}
|
||||||
|
|
||||||
CategoryPreviewGrid(
|
CategoryPreviewGrid(
|
||||||
categories = state.categories,
|
categories = state.categories,
|
||||||
|
|
@ -849,6 +860,46 @@ private fun ErrorHomeCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyHomeContent(
|
||||||
|
dailyQuestion: Question?,
|
||||||
|
onDailyQuestion: () -> Unit,
|
||||||
|
onPacks: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
CloserCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(CloserRadii.Card),
|
||||||
|
containerColor = closerCardColor(alpha = 0.82f),
|
||||||
|
elevation = CloserElevations.Card
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(18.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "You're all caught up",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Nothing needs your attention right now. Come back later or explore a pack together.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
CloserActionButton(
|
||||||
|
label = "Browse packs",
|
||||||
|
onClick = onPacks,
|
||||||
|
style = CloserButtonStyle.Secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomePill(label: String) {
|
private fun HomePill(label: String) {
|
||||||
CloserPill(
|
CloserPill(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import app.closer.domain.repository.QuestionRepository
|
||||||
import app.closer.domain.repository.UserRepository
|
import app.closer.domain.repository.UserRepository
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput
|
||||||
|
import app.closer.ui.home.HomePriorityEngine.Priority
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -323,15 +325,169 @@ class HomeViewModel @Inject constructor(
|
||||||
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
|
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
val primary = buildPrimaryAction()
|
val engineInput = PriorityInput(
|
||||||
|
needsCriticalAction = needsRecovery || needsEncryptionUpgrade,
|
||||||
|
isPaired = isPaired,
|
||||||
|
needsEncryptionUnlock = needsRecovery,
|
||||||
|
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
|
||||||
|
partnerAnsweredUserPending = dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
|
||||||
|
gameWaiting = hasWaitingGame(),
|
||||||
|
challengeWaiting = hasIncompleteChallenge(),
|
||||||
|
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
|
||||||
|
weeklyRecapReady = false, // TODO(Batch 5): wire weekly recap flag
|
||||||
|
capsuleUnlocked = hasUnlockedCapsule(),
|
||||||
|
dateReminder = hasUpcomingDate(),
|
||||||
|
suggestedPackAvailable = categories.isNotEmpty(),
|
||||||
|
exploreGamesAvailable = categories.isNotEmpty()
|
||||||
|
)
|
||||||
|
val priorityOutput = HomePriorityEngine.compute(engineInput)
|
||||||
|
|
||||||
|
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
|
||||||
|
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
|
||||||
val pending = buildPendingActions()
|
val pending = buildPendingActions()
|
||||||
|
|
||||||
return copy(
|
return copy(
|
||||||
primaryAction = primary,
|
primaryAction = primary,
|
||||||
secondaryActions = buildSecondaryActions(primary),
|
secondaryActions = secondary.take(3),
|
||||||
pendingActions = pending.take(3)
|
pendingActions = pending.take(3)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun HomeUiState.toHomeAction(priority: Priority): HomeAction? = when (priority) {
|
||||||
|
Priority.CRITICAL_ACTION ->
|
||||||
|
if (needsRecovery) HomeAction(
|
||||||
|
eyebrow = "Account recovery",
|
||||||
|
title = "Secure your answers before continuing.",
|
||||||
|
body = "A privacy action needs your attention. Complete recovery to keep your shared space safe.",
|
||||||
|
cta = "Start recovery",
|
||||||
|
target = HomeActionTarget.Settings,
|
||||||
|
tone = HomeActionTone.Utility
|
||||||
|
) else if (needsEncryptionUpgrade) HomeAction(
|
||||||
|
eyebrow = "Encryption update",
|
||||||
|
title = "Upgrade your answer security.",
|
||||||
|
body = "Your encryption needs a quick update so your answers stay private.",
|
||||||
|
cta = "Update encryption",
|
||||||
|
target = HomeActionTarget.Settings,
|
||||||
|
tone = HomeActionTone.Utility
|
||||||
|
) else null
|
||||||
|
|
||||||
|
Priority.PAIRING_NEEDED -> HomeAction(
|
||||||
|
eyebrow = "Next best action",
|
||||||
|
title = "Invite your partner into tonight.",
|
||||||
|
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.",
|
||||||
|
cta = "Invite partner",
|
||||||
|
target = HomeActionTarget.InvitePartner,
|
||||||
|
tone = HomeActionTone.Invite
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.ENCRYPTION_UNLOCK_NEEDED -> HomeAction(
|
||||||
|
eyebrow = "Encryption unlock",
|
||||||
|
title = "Unlock your shared answers.",
|
||||||
|
body = "Your couple's encryption needs to be restored. Complete recovery to keep accessing your answers.",
|
||||||
|
cta = "Recover keys",
|
||||||
|
target = HomeActionTarget.Settings,
|
||||||
|
tone = HomeActionTone.Utility
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.REVEAL_READY -> buildDailyQuestionAction(
|
||||||
|
title = "Reveal is ready.",
|
||||||
|
body = "Both of you answered. Open it together when you are both in the right headspace.",
|
||||||
|
cta = "Reveal together"
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.PARTNER_ANSWERED_USER_PENDING -> buildDailyQuestionAction(
|
||||||
|
title = "Your partner answered. Your turn.",
|
||||||
|
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
|
||||||
|
cta = "Answer to unlock reveal"
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.GAME_WAITING -> HomeAction(
|
||||||
|
eyebrow = "Game waiting",
|
||||||
|
title = "Your partner is waiting to play.",
|
||||||
|
body = "A game is ready for the two of you. Jump back in and keep the ritual going.",
|
||||||
|
cta = "Play now",
|
||||||
|
target = HomeActionTarget.Game,
|
||||||
|
tone = HomeActionTone.Ritual
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.CHALLENGE_WAITING -> HomeAction(
|
||||||
|
eyebrow = "Challenge waiting",
|
||||||
|
title = "Today’s small step is ready.",
|
||||||
|
body = "Your connection challenge is waiting for both of you. Show up together tonight.",
|
||||||
|
cta = "View challenge",
|
||||||
|
target = HomeActionTarget.Challenge,
|
||||||
|
tone = HomeActionTone.Ritual
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.DAILY_QUESTION_UNANSWERED -> buildDailyQuestionAction(
|
||||||
|
title = dailyQuestion?.text ?: "Tonight's question is ready.",
|
||||||
|
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
|
||||||
|
cta = "Answer privately"
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.WEEKLY_RECAP_READY -> HomeAction(
|
||||||
|
eyebrow = "Your week together",
|
||||||
|
title = "Look back at what you built this week.",
|
||||||
|
body = "Reveals, answers, and small rituals are summarized for just the two of you.",
|
||||||
|
cta = "See recap",
|
||||||
|
target = HomeActionTarget.AnswerHistory,
|
||||||
|
tone = HomeActionTone.Reflection
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.CAPSULE_UNLOCKED -> HomeAction(
|
||||||
|
eyebrow = "Memory capsule",
|
||||||
|
title = "A saved memory is ready to open.",
|
||||||
|
body = "One of your time capsules unlocked. Open it together and remember why you saved it.",
|
||||||
|
cta = "Open capsule",
|
||||||
|
target = HomeActionTarget.MemoryCapsule,
|
||||||
|
tone = HomeActionTone.Reflection
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.DATE_REMINDER -> HomeAction(
|
||||||
|
eyebrow = "Date coming up",
|
||||||
|
title = "A planned moment is almost here.",
|
||||||
|
body = "You saved a date idea together. Check the details before the night arrives.",
|
||||||
|
cta = "View date",
|
||||||
|
target = HomeActionTarget.DatePlan,
|
||||||
|
tone = HomeActionTone.Ritual
|
||||||
|
)
|
||||||
|
|
||||||
|
Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
|
||||||
|
HomeAction(
|
||||||
|
eyebrow = "Suggested pack",
|
||||||
|
title = category.category.displayName.ifBlank { "Question pack" },
|
||||||
|
body = "${category.questionCount} prompts for when you want a different doorway into the conversation.",
|
||||||
|
cta = "Open pack",
|
||||||
|
target = HomeActionTarget.QuestionPacks,
|
||||||
|
tone = HomeActionTone.Pack,
|
||||||
|
categoryId = category.category.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Priority.EXPLORE_GAMES -> HomeAction(
|
||||||
|
eyebrow = "Explore",
|
||||||
|
title = "Try a game together.",
|
||||||
|
body = "Playful ways to connect when you both want something light.",
|
||||||
|
cta = "Browse games",
|
||||||
|
target = HomeActionTarget.QuestionPacks,
|
||||||
|
tone = HomeActionTone.Starter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HomeUiState.buildDailyQuestionAction(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
cta: String
|
||||||
|
): HomeAction = HomeAction(
|
||||||
|
eyebrow = "Tonight's prompt",
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
cta = cta,
|
||||||
|
target = HomeActionTarget.DailyQuestion,
|
||||||
|
tone = HomeActionTone.Daily,
|
||||||
|
metric = dailyQuestion?.category?.takeIf { it.isNotBlank() }?.toHomeLabel()
|
||||||
|
)
|
||||||
|
|
||||||
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
|
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
|
||||||
if (!isPaired) return emptyList()
|
if (!isPaired) return emptyList()
|
||||||
|
|
||||||
|
|
@ -420,147 +576,6 @@ class HomeViewModel @Inject constructor(
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun HomeUiState.buildPrimaryAction(): HomeAction {
|
|
||||||
val dailyQuestionId = dailyQuestion?.id
|
|
||||||
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
|
|
||||||
val userRevealed = dailyQuestionId != null && answerStats.latest?.let { latest ->
|
|
||||||
latest.questionId == dailyQuestionId && latest.isRevealed
|
|
||||||
} == true
|
|
||||||
|
|
||||||
return when {
|
|
||||||
!isPaired -> HomeAction(
|
|
||||||
eyebrow = "Next best action",
|
|
||||||
title = "Invite your partner into tonight.",
|
|
||||||
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.",
|
|
||||||
cta = "Invite partner",
|
|
||||||
target = HomeActionTarget.InvitePartner,
|
|
||||||
tone = HomeActionTone.Invite
|
|
||||||
)
|
|
||||||
|
|
||||||
userRevealed -> HomeAction(
|
|
||||||
eyebrow = "Tonight's prompt",
|
|
||||||
title = "You opened a conversation tonight.",
|
|
||||||
body = dailyQuestion?.text ?: "You revealed an answer together. What comes next is up to both of you.",
|
|
||||||
cta = "Try a follow-up",
|
|
||||||
target = HomeActionTarget.DailyQuestion,
|
|
||||||
tone = HomeActionTone.Daily,
|
|
||||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
||||||
)
|
|
||||||
|
|
||||||
dailyQuestionState == DailyQuestionState.BOTH_ANSWERED -> HomeAction(
|
|
||||||
eyebrow = "Tonight's prompt",
|
|
||||||
title = "Reveal is ready.",
|
|
||||||
body = "Both of you answered. Open it together when you are both in the right headspace.",
|
|
||||||
cta = "Reveal together",
|
|
||||||
target = HomeActionTarget.DailyQuestion,
|
|
||||||
tone = HomeActionTone.Daily,
|
|
||||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
||||||
)
|
|
||||||
|
|
||||||
dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> HomeAction(
|
|
||||||
eyebrow = "Tonight's prompt",
|
|
||||||
title = "You showed up tonight. Waiting for your partner.",
|
|
||||||
body = "Your answer is private until they answer too. No pressure — the reveal waits for both of you.",
|
|
||||||
cta = "Send a gentle reminder",
|
|
||||||
target = HomeActionTarget.DailyQuestion,
|
|
||||||
tone = HomeActionTone.Daily,
|
|
||||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
||||||
)
|
|
||||||
|
|
||||||
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> HomeAction(
|
|
||||||
eyebrow = "Tonight's prompt",
|
|
||||||
title = "Your partner answered. Your turn.",
|
|
||||||
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
|
|
||||||
cta = "Answer to unlock reveal",
|
|
||||||
target = HomeActionTarget.DailyQuestion,
|
|
||||||
tone = HomeActionTone.Daily,
|
|
||||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
||||||
)
|
|
||||||
|
|
||||||
userAnswered -> HomeAction(
|
|
||||||
eyebrow = "Tonight's prompt",
|
|
||||||
title = dailyQuestion?.text ?: "Answer tonight's question.",
|
|
||||||
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
|
|
||||||
cta = "Answer now",
|
|
||||||
target = HomeActionTarget.DailyQuestion,
|
|
||||||
tone = HomeActionTone.Daily,
|
|
||||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
||||||
)
|
|
||||||
|
|
||||||
answerStats.private > 0 -> HomeAction(
|
|
||||||
eyebrow = "Saved privately",
|
|
||||||
title = "You have ${answerStats.private} reflection${if (answerStats.private == 1) "" else "s"} waiting.",
|
|
||||||
body = "Review what you saved and choose whether tonight is the right time to open one up.",
|
|
||||||
cta = "Review reflections",
|
|
||||||
target = HomeActionTarget.AnswerHistory,
|
|
||||||
tone = HomeActionTone.Reflection,
|
|
||||||
metric = "${answerStats.revealed} revealed"
|
|
||||||
)
|
|
||||||
|
|
||||||
streakCount > 0 -> HomeAction(
|
|
||||||
eyebrow = "Shared ritual",
|
|
||||||
title = "$streakCount night${if (streakCount == 1) "" else "s"} showing up.",
|
|
||||||
body = "Keep it light: answer one prompt, revisit a saved reflection, or choose a pack that fits tonight.",
|
|
||||||
cta = "Keep going",
|
|
||||||
target = HomeActionTarget.DailyQuestion,
|
|
||||||
tone = HomeActionTone.Ritual,
|
|
||||||
metric = "${answerStats.total} saved"
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> HomeAction(
|
|
||||||
eyebrow = "Gentle start",
|
|
||||||
title = "Tonight's question is ready.",
|
|
||||||
body = "${dailyQuestion?.text ?: "A small prompt is enough. Build the habit around attention, not pressure."}",
|
|
||||||
cta = "Answer privately",
|
|
||||||
target = HomeActionTarget.DailyQuestion,
|
|
||||||
tone = HomeActionTone.Starter,
|
|
||||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun HomeUiState.buildSecondaryActions(primary: HomeAction): List<HomeAction> {
|
|
||||||
val actions = mutableListOf<HomeAction>()
|
|
||||||
|
|
||||||
answerStats.latest?.let { latest ->
|
|
||||||
if (primary.target != HomeActionTarget.AnswerHistory) {
|
|
||||||
actions += HomeAction(
|
|
||||||
eyebrow = if (latest.isRevealed) "Revealed" else "Private",
|
|
||||||
title = "Return to your latest reflection.",
|
|
||||||
body = latest.questionText,
|
|
||||||
cta = "Open history",
|
|
||||||
target = HomeActionTarget.AnswerHistory,
|
|
||||||
tone = HomeActionTone.Reflection
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
categories.firstOrNull()?.let { category ->
|
|
||||||
actions += HomeAction(
|
|
||||||
eyebrow = "Suggested pack",
|
|
||||||
title = category.category.displayName.ifBlank { "Question pack" },
|
|
||||||
body = "${category.questionCount} prompts for when you want a different doorway into the conversation.",
|
|
||||||
cta = "Open pack",
|
|
||||||
target = HomeActionTarget.QuestionPacks,
|
|
||||||
tone = HomeActionTone.Pack,
|
|
||||||
categoryId = category.category.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
actions += HomeAction(
|
|
||||||
eyebrow = "Tune the ritual",
|
|
||||||
title = "Adjust your space.",
|
|
||||||
body = "Manage reminders, partner state, privacy, and account details when you need to.",
|
|
||||||
cta = "Settings",
|
|
||||||
target = HomeActionTarget.Settings,
|
|
||||||
tone = HomeActionTone.Utility
|
|
||||||
)
|
|
||||||
|
|
||||||
return actions.take(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toHomeLabel(): String =
|
private fun String.toHomeLabel(): String =
|
||||||
split("_", "-")
|
split("_", "-")
|
||||||
.filter { part -> part.isNotBlank() }
|
.filter { part -> part.isNotBlank() }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
package app.closer.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class NotificationRateLimiterTest {
|
||||||
|
|
||||||
|
private fun createLimiter(): NotificationRateLimiter {
|
||||||
|
val context = mockk<Context>()
|
||||||
|
every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns InMemorySharedPreferences()
|
||||||
|
return NotificationRateLimiter(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partner trigger limit allows up to two per day`() {
|
||||||
|
val limiter = createLimiter()
|
||||||
|
|
||||||
|
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
|
||||||
|
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
|
||||||
|
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
|
||||||
|
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
|
||||||
|
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
|
||||||
|
|
||||||
|
assertEquals(2, limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminder limit allows one per day`() {
|
||||||
|
val limiter = createLimiter()
|
||||||
|
|
||||||
|
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
|
||||||
|
limiter.record(NotificationRateLimiter.Type.REMINDER)
|
||||||
|
assertFalse(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
|
||||||
|
|
||||||
|
assertEquals(1, limiter.count(NotificationRateLimiter.Type.REMINDER))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly total limit blocks when four notifications have been recorded`() {
|
||||||
|
val limiter = createLimiter()
|
||||||
|
|
||||||
|
// Record four partner-trigger notifications directly so the weekly total
|
||||||
|
// reaches its cap independent of the daily partner-trigger limit.
|
||||||
|
repeat(4) {
|
||||||
|
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(4, limiter.totalCount())
|
||||||
|
assertFalse(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
|
||||||
|
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mixed types count toward weekly total`() {
|
||||||
|
val limiter = createLimiter()
|
||||||
|
|
||||||
|
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
|
||||||
|
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
|
||||||
|
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
|
||||||
|
limiter.record(NotificationRateLimiter.Type.REMINDER)
|
||||||
|
|
||||||
|
assertEquals(2, limiter.totalCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminder does not count toward partner daily limit`() {
|
||||||
|
val limiter = createLimiter()
|
||||||
|
|
||||||
|
repeat(2) {
|
||||||
|
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
|
||||||
|
}
|
||||||
|
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
|
||||||
|
assertEquals(0, limiter.count(NotificationRateLimiter.Type.REMINDER))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InMemorySharedPreferences : SharedPreferences {
|
||||||
|
private val values = mutableMapOf<String, Any>()
|
||||||
|
private val listeners = mutableListOf<SharedPreferences.OnSharedPreferenceChangeListener>()
|
||||||
|
|
||||||
|
override fun getAll(): Map<String, *> = values.toMap()
|
||||||
|
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
|
||||||
|
override fun getStringSet(key: String?, defValues: Set<String>?): Set<String>? = values[key] as? Set<String> ?: defValues
|
||||||
|
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
|
||||||
|
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
|
||||||
|
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
|
||||||
|
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
|
||||||
|
override fun contains(key: String?): Boolean = key in values
|
||||||
|
|
||||||
|
override fun edit(): SharedPreferences.Editor = InMemoryEditor()
|
||||||
|
|
||||||
|
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
|
||||||
|
listener?.let { listeners.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
|
||||||
|
listener?.let { listeners.remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class InMemoryEditor : SharedPreferences.Editor {
|
||||||
|
private val pending = mutableMapOf<String, Any?>()
|
||||||
|
|
||||||
|
override fun putString(key: String?, value: String?): SharedPreferences.Editor = apply {
|
||||||
|
pending[key!!] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putStringSet(key: String?, values: Set<String>?): SharedPreferences.Editor = apply {
|
||||||
|
pending[key!!] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = apply {
|
||||||
|
pending[key!!] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = apply {
|
||||||
|
pending[key!!] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = apply {
|
||||||
|
pending[key!!] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = apply {
|
||||||
|
pending[key!!] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun remove(key: String?): SharedPreferences.Editor = apply {
|
||||||
|
pending[key!!] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clear(): SharedPreferences.Editor = apply {
|
||||||
|
pending.clear()
|
||||||
|
values.keys.forEach { pending[it] = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun commit(): Boolean {
|
||||||
|
apply()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun apply() {
|
||||||
|
pending.forEach { (key, value) ->
|
||||||
|
if (value == null) values.remove(key) else values[key] = value
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package app.closer.notifications
|
||||||
|
|
||||||
|
import app.closer.core.navigation.AppRoute
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PartnerNotificationTypeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote type mapping covers all batch 6 types`() {
|
||||||
|
assertNotNull(PartnerNotificationType.fromRemoteType("partner_answered"))
|
||||||
|
assertNotNull(PartnerNotificationType.fromRemoteType("reveal_ready"))
|
||||||
|
assertNotNull(PartnerNotificationType.fromRemoteType("partner_started_game"))
|
||||||
|
assertNotNull(PartnerNotificationType.fromRemoteType("partner_completed_part"))
|
||||||
|
assertNotNull(PartnerNotificationType.fromRemoteType("challenge_waiting"))
|
||||||
|
assertNotNull(PartnerNotificationType.fromRemoteType("memory_capsule_unlocked"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown remote types are rejected`() {
|
||||||
|
assertNull(PartnerNotificationType.fromRemoteType("arbitrary_backend_title"))
|
||||||
|
assertNull(PartnerNotificationType.fromRemoteType(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reveal ready falls back to history when question id missing`() {
|
||||||
|
val route = PartnerNotificationType.REVEAL_READY.routeFor(PartnerNotificationPayload())
|
||||||
|
|
||||||
|
assertEquals(AppRoute.ANSWER_HISTORY, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partner answered deep links to daily question`() {
|
||||||
|
val route = PartnerNotificationType.PARTNER_ANSWERED.routeFor(PartnerNotificationPayload())
|
||||||
|
assertEquals(AppRoute.DAILY_QUESTION, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `game notifications deep link to play hub`() {
|
||||||
|
assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_STARTED_GAME.routeFor(PartnerNotificationPayload()))
|
||||||
|
assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_COMPLETED_PART.routeFor(PartnerNotificationPayload()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `challenge waiting deep links to challenges`() {
|
||||||
|
val route = PartnerNotificationType.CHALLENGE_WAITING.routeFor(PartnerNotificationPayload())
|
||||||
|
assertEquals(AppRoute.CONNECTION_CHALLENGES, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `capsule unlocked deep links to memory lane`() {
|
||||||
|
val route = PartnerNotificationType.CAPSULE_UNLOCKED.routeFor(PartnerNotificationPayload())
|
||||||
|
assertEquals(AppRoute.MEMORY_LANE, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partner trigger types use the partner action channel`() {
|
||||||
|
assertTrue(
|
||||||
|
listOf(
|
||||||
|
PartnerNotificationType.PARTNER_ANSWERED,
|
||||||
|
PartnerNotificationType.REVEAL_READY,
|
||||||
|
PartnerNotificationType.PARTNER_STARTED_GAME,
|
||||||
|
PartnerNotificationType.PARTNER_COMPLETED_PART,
|
||||||
|
PartnerNotificationType.CAPSULE_UNLOCKED
|
||||||
|
).all { it.channelId == NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS || it.channelId == NotificationChannelSetup.CHANNEL_GAMES }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminder type uses reminders channel`() {
|
||||||
|
assertEquals(
|
||||||
|
NotificationChannelSetup.CHANNEL_REMINDERS,
|
||||||
|
PartnerNotificationType.CHALLENGE_WAITING.channelId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notification copy never includes placeholder or answer text`() {
|
||||||
|
PartnerNotificationType.entries.forEach { type ->
|
||||||
|
assertTrue("${type.name} title must not be blank", type.title.isNotBlank())
|
||||||
|
assertTrue("${type.name} body must not be blank", type.body.isNotBlank())
|
||||||
|
assertTrue(
|
||||||
|
"${type.name} title must be static and safe",
|
||||||
|
type.title !in listOf("{questionId}", "{answer}")
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"${type.name} body must be static and safe",
|
||||||
|
type.body !in listOf("{questionId}", "{answer}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package app.closer.notifications
|
||||||
|
|
||||||
|
import app.closer.core.notifications.QuietHours
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
class QuietHoursManagerTest {
|
||||||
|
|
||||||
|
private val manager = QuietHoursManager()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quiet hours disabled always returns false`() {
|
||||||
|
val quietHours = QuietHours(enabled = false, startHour = 22, endHour = 8)
|
||||||
|
val now = calendarAt(23, 0)
|
||||||
|
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same day window is in quiet hours`() {
|
||||||
|
val quietHours = QuietHours(enabled = true, startHour = 10, endHour = 12)
|
||||||
|
assertTrue(manager.isInQuietHours(quietHours, calendarAt(10, 30)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same day window outside quiet hours returns false`() {
|
||||||
|
val quietHours = QuietHours(enabled = true, startHour = 10, endHour = 12)
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, calendarAt(13, 0)))
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, calendarAt(9, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `crossing midnight window detects late night quiet hours`() {
|
||||||
|
val quietHours = QuietHours(enabled = true, startHour = 22, endHour = 8)
|
||||||
|
assertTrue(manager.isInQuietHours(quietHours, calendarAt(23, 30)))
|
||||||
|
assertTrue(manager.isInQuietHours(quietHours, calendarAt(22, 0)))
|
||||||
|
assertTrue(manager.isInQuietHours(quietHours, calendarAt(3, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `crossing midnight window detects non quiet hours`() {
|
||||||
|
val quietHours = QuietHours(enabled = true, startHour = 22, endHour = 8)
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, calendarAt(12, 0)))
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, calendarAt(8, 1)))
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, calendarAt(21, 59)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `boundary minutes are respected`() {
|
||||||
|
val quietHours = QuietHours(enabled = true, startHour = 22, startMinute = 15, endHour = 8, endMinute = 30)
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, calendarAt(22, 14)))
|
||||||
|
assertTrue(manager.isInQuietHours(quietHours, calendarAt(22, 15)))
|
||||||
|
assertTrue(manager.isInQuietHours(quietHours, calendarAt(8, 30)))
|
||||||
|
assertFalse(manager.isInQuietHours(quietHours, calendarAt(8, 31)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calendarAt(hour: Int, minute: Int): Calendar {
|
||||||
|
return Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, hour)
|
||||||
|
set(Calendar.MINUTE, minute)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
package app.closer.ui.home
|
||||||
|
|
||||||
|
import app.closer.ui.home.HomePriorityEngine.Input
|
||||||
|
import app.closer.ui.home.HomePriorityEngine.Priority
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HomePriorityEngineTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty input returns pairing as primary because pairing is the default state`() {
|
||||||
|
val output = HomePriorityEngine.compute(Input())
|
||||||
|
|
||||||
|
assertEquals(Priority.PAIRING_NEEDED, output.primary?.priority)
|
||||||
|
assertEquals(emptyList<Priority>(), output.secondary.map { it.priority })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `critical action always wins primary`() {
|
||||||
|
val input = Input(
|
||||||
|
needsCriticalAction = true,
|
||||||
|
revealReady = true,
|
||||||
|
dailyQuestionUnanswered = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.CRITICAL_ACTION, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pairing needed outranks daily question`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = false,
|
||||||
|
dailyQuestionUnanswered = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.PAIRING_NEEDED, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encryption unlock outranks reveal ready`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
needsEncryptionUnlock = true,
|
||||||
|
revealReady = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.ENCRYPTION_UNLOCK_NEEDED, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reveal ready is primary when no blockers`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
revealReady = true,
|
||||||
|
dailyQuestionUnanswered = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.REVEAL_READY, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partner answered outranks daily question unanswered`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
partnerAnsweredUserPending = true,
|
||||||
|
dailyQuestionUnanswered = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.PARTNER_ANSWERED_USER_PENDING, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `game waiting outranks challenge waiting`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
gameWaiting = true,
|
||||||
|
challengeWaiting = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.GAME_WAITING, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `challenge waiting outranks daily question`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
challengeWaiting = true,
|
||||||
|
dailyQuestionUnanswered = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.CHALLENGE_WAITING, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `daily question is primary when no higher priority items`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
dailyQuestionUnanswered = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.DAILY_QUESTION_UNANSWERED, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly recap outranks capsule and date reminder`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
weeklyRecapReady = true,
|
||||||
|
capsuleUnlocked = true,
|
||||||
|
dateReminder = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.WEEKLY_RECAP_READY, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `capsule unlocked outranks date reminder`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
capsuleUnlocked = true,
|
||||||
|
dateReminder = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.CAPSULE_UNLOCKED, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `date reminder outranks suggested pack and explore games`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
dateReminder = true,
|
||||||
|
suggestedPackAvailable = true,
|
||||||
|
exploreGamesAvailable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.DATE_REMINDER, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `suggested pack outranks explore games`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
suggestedPackAvailable = true,
|
||||||
|
exploreGamesAvailable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.SUGGESTED_PACK, output.primary?.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `secondary cards exclude primary`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
revealReady = true,
|
||||||
|
dailyQuestionUnanswered = true,
|
||||||
|
weeklyRecapReady = true,
|
||||||
|
capsuleUnlocked = true,
|
||||||
|
dateReminder = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.REVEAL_READY, output.primary?.priority)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
Priority.DAILY_QUESTION_UNANSWERED,
|
||||||
|
Priority.WEEKLY_RECAP_READY,
|
||||||
|
Priority.CAPSULE_UNLOCKED
|
||||||
|
),
|
||||||
|
output.secondary.map { it.priority }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `secondary cards are limited to three and partner triggered first`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
partnerAnsweredUserPending = true,
|
||||||
|
gameWaiting = true,
|
||||||
|
challengeWaiting = true,
|
||||||
|
dailyQuestionUnanswered = true,
|
||||||
|
weeklyRecapReady = true,
|
||||||
|
capsuleUnlocked = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(Priority.PARTNER_ANSWERED_USER_PENDING, output.primary?.priority)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
Priority.GAME_WAITING,
|
||||||
|
Priority.CHALLENGE_WAITING,
|
||||||
|
Priority.DAILY_QUESTION_UNANSWERED
|
||||||
|
),
|
||||||
|
output.secondary.map { it.priority }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generic browse items do not appear before partner triggered actions`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
gameWaiting = true,
|
||||||
|
dailyQuestionUnanswered = true,
|
||||||
|
suggestedPackAvailable = true,
|
||||||
|
exploreGamesAvailable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val output = HomePriorityEngine.compute(input)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(Priority.DAILY_QUESTION_UNANSWERED),
|
||||||
|
output.secondary.map { it.priority }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primary priority convenience returns pairing needed for default empty input`() {
|
||||||
|
assertEquals(Priority.PAIRING_NEEDED, HomePriorityEngine.primaryPriority(Input()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primary priority convenience returns highest active priority`() {
|
||||||
|
val input = Input(
|
||||||
|
isPaired = true,
|
||||||
|
revealReady = true,
|
||||||
|
dailyQuestionUnanswered = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Priority.REVEAL_READY, HomePriorityEngine.primaryPriority(input))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue