feat(outcomes): add 30/60/90 day check-in flow with baseline + reminders
This commit is contained in:
parent
535c0ce668
commit
57a3e35359
|
|
@ -73,6 +73,7 @@ import app.closer.ui.settings.PrivacyScreen
|
|||
import app.closer.ui.settings.RelationshipSettingsScreen
|
||||
import app.closer.ui.settings.SettingsScreen
|
||||
import app.closer.ui.settings.SubscriptionScreen
|
||||
import app.closer.ui.outcomes.YourProgressScreen
|
||||
import app.closer.ui.wheel.CategoryPickerScreen
|
||||
import app.closer.ui.wheel.SpinWheelScreen
|
||||
import app.closer.ui.wheel.WheelCompleteScreen
|
||||
|
|
@ -443,6 +444,11 @@ fun AppNavigation(
|
|||
composable(route = AppRoute.DELETE_ACCOUNT) {
|
||||
DeleteAccountScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.YOUR_PROGRESS) {
|
||||
YourProgressScreen(
|
||||
onBack = navigateBackOrHome
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ object AppRoute {
|
|||
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
||||
const val RECOVERY = "recovery"
|
||||
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
|
||||
const val YOUR_PROGRESS = "your_progress"
|
||||
|
||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||
const val QUESTION_THREAD =
|
||||
|
|
@ -112,7 +113,8 @@ object AppRoute {
|
|||
Definition(MEMORY_LANE, "Memory Lane", "play"),
|
||||
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play"),
|
||||
Definition(RECOVERY, "Unlock Answers", "security"),
|
||||
Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security")
|
||||
Definition(ENCRYPTION_UPGRADE, "Secure Answers", "security"),
|
||||
Definition(YOUR_PROGRESS, "Your Progress", "settings")
|
||||
)
|
||||
|
||||
val topLevelRoutes = setOf(
|
||||
|
|
@ -136,7 +138,8 @@ object AppRoute {
|
|||
EMAIL_INVITE,
|
||||
ACCEPT_INVITE,
|
||||
INVITE_CONFIRM,
|
||||
PAYWALL
|
||||
PAYWALL,
|
||||
YOUR_PROGRESS
|
||||
)
|
||||
|
||||
val drillInRoutes = setOf(
|
||||
|
|
@ -166,7 +169,8 @@ object AppRoute {
|
|||
DELETE_ACCOUNT,
|
||||
CONNECTION_CHALLENGES,
|
||||
MEMORY_LANE,
|
||||
WAITING_FOR_PARTNER
|
||||
WAITING_FOR_PARTNER,
|
||||
YOUR_PROGRESS
|
||||
)
|
||||
|
||||
fun titleFor(route: String?): String? =
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import app.closer.core.notifications.QuietHours
|
||||
import app.closer.domain.repository.AppSettings
|
||||
|
|
@ -32,6 +33,10 @@ class SettingsDataStore @Inject constructor(
|
|||
private val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
|
||||
private val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
|
||||
private val OUTCOME_REMINDER_ENABLED = booleanPreferencesKey("outcome_reminder_enabled")
|
||||
private val OUTCOME_BASELINE_SHOWN_AT = longPreferencesKey("outcome_baseline_shown_at")
|
||||
private val OUTCOME_LAST_PROMPTED_DAY = stringPreferencesKey("outcome_last_prompted_day")
|
||||
|
||||
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
dailyReminderEnabled = prefs[DAILY_REMINDER] ?: true,
|
||||
|
|
@ -47,10 +52,22 @@ class SettingsDataStore @Inject constructor(
|
|||
endMinute = prefs[QUIET_HOURS_END_MINUTE] ?: 0
|
||||
),
|
||||
onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false,
|
||||
themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE])
|
||||
themeMode = ThemeMode.fromStorageValue(prefs[THEME_MODE]),
|
||||
outcomeReminderEnabled = prefs[OUTCOME_REMINDER_ENABLED] ?: true,
|
||||
outcomeBaselineShownAt = prefs[OUTCOME_BASELINE_SHOWN_AT] ?: 0L,
|
||||
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun setOutcomeReminderEnabled(enabled: Boolean) =
|
||||
dataStore.edit { it[OUTCOME_REMINDER_ENABLED] = enabled }.let {}
|
||||
|
||||
override suspend fun markOutcomeBaselineShown() =
|
||||
dataStore.edit { it[OUTCOME_BASELINE_SHOWN_AT] = System.currentTimeMillis() }.let {}
|
||||
|
||||
override suspend fun setOutcomeLastPromptedDay(dayKey: String) =
|
||||
dataStore.edit { it[OUTCOME_LAST_PROMPTED_DAY] = dayKey }.let {}
|
||||
|
||||
override suspend fun setDailyReminder(enabled: Boolean) =
|
||||
dataStore.edit { it[DAILY_REMINDER] = enabled }.let {}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ object FirestoreCollections {
|
|||
const val HOW_WELL = "how_well"
|
||||
}
|
||||
|
||||
// Standalone subcollection name that appears under both couples/{id} and users/{uid}
|
||||
const val OUTCOMES = "outcomes"
|
||||
|
||||
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────
|
||||
object DailyQuestion {
|
||||
const val ANSWERS = "answers"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package app.closer.data.remote
|
||||
|
||||
import app.closer.domain.model.Outcome
|
||||
import app.closer.domain.model.OutcomeDayKey
|
||||
import app.closer.domain.model.OutcomeScores
|
||||
import com.google.firebase.firestore.DocumentSnapshot
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.google.firebase.functions.FirebaseFunctions
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Singleton
|
||||
class FirestoreOutcomeDataSource @Inject constructor(
|
||||
private val db: FirebaseFirestore,
|
||||
private val functions: FirebaseFunctions
|
||||
) {
|
||||
suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Unit =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
functions.getHttpsCallable("submitOutcomeCallable")
|
||||
.call(
|
||||
mapOf(
|
||||
"coupleId" to coupleId,
|
||||
"dayKey" to dayKey,
|
||||
"scores" to scores.toMap()
|
||||
)
|
||||
)
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
outcomeRef(coupleId, dayKey)
|
||||
.get()
|
||||
.addOnSuccessListener { snap ->
|
||||
cont.resume(if (snap.exists()) snap.toOutcome() else null)
|
||||
}
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
suspend fun getOutcomes(coupleId: String): List<Outcome> {
|
||||
val snapshot = db
|
||||
.collection(FirestoreCollections.COUPLES)
|
||||
.document(coupleId)
|
||||
.collection(FirestoreCollections.OUTCOMES)
|
||||
.get()
|
||||
.await()
|
||||
return snapshot.documents.mapNotNull { it.toOutcome() }
|
||||
}
|
||||
|
||||
private fun outcomeRef(coupleId: String, dayKey: OutcomeDayKey) =
|
||||
db.collection(FirestoreCollections.COUPLES)
|
||||
.document(coupleId)
|
||||
.collection(FirestoreCollections.OUTCOMES)
|
||||
.document(dayKey)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun DocumentSnapshot.toOutcome(): Outcome? {
|
||||
val data = this.data ?: return null
|
||||
val dayKey = data["dayKey"] as? String ?: return null
|
||||
return Outcome(
|
||||
dayKey = dayKey,
|
||||
baseline = (data["baseline"] as? Map<*, *>)?.toOutcomeScores(),
|
||||
scores = (data["scores"] as? Map<*, *>)?.toOutcomeScores(),
|
||||
delta = (data["delta"] as? Map<*, *>)?.toOutcomeScores(),
|
||||
answeredBy = (data["answeredBy"] as? List<String>) ?: emptyList(),
|
||||
createdAt = (data["createdAt"] as? com.google.firebase.Timestamp)?.toDate()?.time,
|
||||
updatedAt = (data["updatedAt"] as? com.google.firebase.Timestamp)?.toDate()?.time
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun Map<*, *>.toOutcomeScores(): OutcomeScores? {
|
||||
val map = this as? Map<String, Int> ?: return null
|
||||
val connection = map["connection"] ?: return null
|
||||
val communication = map["communication"] ?: return null
|
||||
val intimacy = map["intimacy"] ?: return null
|
||||
val happiness = map["happiness"] ?: return null
|
||||
return runCatching { OutcomeScores(connection, communication, intimacy, happiness) }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package app.closer.data.repository
|
||||
|
||||
import app.closer.core.crash.CrashReporter
|
||||
import app.closer.data.remote.FirestoreOutcomeDataSource
|
||||
import app.closer.domain.model.Outcome
|
||||
import app.closer.domain.model.OutcomeDayKey
|
||||
import app.closer.domain.model.OutcomeScores
|
||||
import app.closer.domain.repository.OutcomeRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OutcomeRepositoryImpl @Inject constructor(
|
||||
private val dataSource: FirestoreOutcomeDataSource,
|
||||
private val crashReporter: CrashReporter
|
||||
) : OutcomeRepository {
|
||||
|
||||
override suspend fun submitOutcome(
|
||||
coupleId: String,
|
||||
dayKey: OutcomeDayKey,
|
||||
scores: OutcomeScores
|
||||
): Result<Unit> = runCatching {
|
||||
dataSource.submitOutcome(coupleId, dayKey, scores)
|
||||
}.onFailure { crashReporter.recordException(it) }
|
||||
|
||||
override suspend fun getOutcomes(coupleId: String): List<Outcome> =
|
||||
runCatching { dataSource.getOutcomes(coupleId) }
|
||||
.onFailure { crashReporter.recordException(it) }
|
||||
.getOrDefault(emptyList())
|
||||
|
||||
override suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome? =
|
||||
runCatching { dataSource.getOutcome(coupleId, dayKey) }
|
||||
.onFailure { crashReporter.recordException(it) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import app.closer.data.repository.BucketListRepositoryImpl
|
|||
import app.closer.data.repository.CoupleRepositoryImpl
|
||||
import app.closer.data.repository.DateMatchRepositoryImpl
|
||||
import app.closer.data.repository.DatePlanRepositoryImpl
|
||||
import app.closer.data.repository.OutcomeRepositoryImpl
|
||||
import app.closer.data.repository.QuestionSessionRepositoryImpl
|
||||
import app.closer.data.repository.FirebaseAuthRepositoryImpl
|
||||
import app.closer.data.repository.InviteRepositoryImpl
|
||||
|
|
@ -22,6 +23,7 @@ import app.closer.domain.repository.CoupleRepository
|
|||
import app.closer.domain.repository.DateMatchRepository
|
||||
import app.closer.domain.repository.DatePlanRepository
|
||||
import app.closer.domain.repository.QuestionSessionRepository
|
||||
import app.closer.domain.repository.OutcomeRepository
|
||||
import app.closer.domain.repository.InviteRepository
|
||||
import app.closer.domain.repository.LocalAnswerRepository
|
||||
import app.closer.domain.repository.QuestionRepository
|
||||
|
|
@ -79,4 +81,7 @@ abstract class RepositoryModule {
|
|||
|
||||
@Binds @Singleton
|
||||
abstract fun bindQuestionSessionRepository(impl: QuestionSessionRepositoryImpl): QuestionSessionRepository
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindOutcomeRepository(impl: OutcomeRepositoryImpl): OutcomeRepository
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package app.closer.domain.model
|
||||
|
||||
/**
|
||||
* Manual test flow:
|
||||
* 1. Pair two test users. Open Settings → Your Progress. Baseline should be empty.
|
||||
* 2. Submit baseline from either member via outcome dialog. Return to Your Progress; baseline shown.
|
||||
* 3. Submit follow-up day_30 outcome. Deltas should compute against baseline.
|
||||
* 4. Verify invalid dayKey or out-of-range scores rejected by callable.
|
||||
*/
|
||||
|
||||
typealias OutcomeDayKey = String
|
||||
|
||||
enum class OutcomeDay(val key: OutcomeDayKey, val label: String) {
|
||||
BASELINE("day_0", "Baseline"),
|
||||
DAY_30("day_30", "30-day"),
|
||||
DAY_60("day_60", "60-day"),
|
||||
DAY_90("day_90", "90-day");
|
||||
|
||||
companion object {
|
||||
fun fromKey(key: OutcomeDayKey): OutcomeDay? = entries.firstOrNull { it.key == key }
|
||||
}
|
||||
}
|
||||
|
||||
data class OutcomeScores(
|
||||
val connection: Int,
|
||||
val communication: Int,
|
||||
val intimacy: Int,
|
||||
val happiness: Int
|
||||
) {
|
||||
init {
|
||||
require(connection in 1..10) { "connection must be 1-10" }
|
||||
require(communication in 1..10) { "communication must be 1-10" }
|
||||
require(intimacy in 1..10) { "intimacy must be 1-10" }
|
||||
require(happiness in 1..10) { "happiness must be 1-10" }
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Int> = mapOf(
|
||||
"connection" to connection,
|
||||
"communication" to communication,
|
||||
"intimacy" to intimacy,
|
||||
"happiness" to happiness
|
||||
)
|
||||
}
|
||||
|
||||
data class Outcome(
|
||||
val dayKey: OutcomeDayKey,
|
||||
val baseline: OutcomeScores? = null,
|
||||
val scores: OutcomeScores? = null,
|
||||
val delta: OutcomeScores? = null,
|
||||
val answeredBy: List<String> = emptyList(),
|
||||
val createdAt: Long? = null,
|
||||
val updatedAt: Long? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package app.closer.domain.repository
|
||||
|
||||
import app.closer.domain.model.Outcome
|
||||
import app.closer.domain.model.OutcomeDayKey
|
||||
import app.closer.domain.model.OutcomeScores
|
||||
|
||||
interface OutcomeRepository {
|
||||
suspend fun submitOutcome(coupleId: String, dayKey: OutcomeDayKey, scores: OutcomeScores): Result<Unit>
|
||||
suspend fun getOutcomes(coupleId: String): List<Outcome>
|
||||
suspend fun getOutcome(coupleId: String, dayKey: OutcomeDayKey): Outcome?
|
||||
}
|
||||
|
|
@ -22,7 +22,10 @@ data class AppSettings(
|
|||
val quietHoursEnabled: Boolean = false,
|
||||
val quietHours: QuietHours = QuietHours(),
|
||||
val onboardingComplete: Boolean = false,
|
||||
val themeMode: ThemeMode = ThemeMode.DEVICE
|
||||
val themeMode: ThemeMode = ThemeMode.DEVICE,
|
||||
val outcomeReminderEnabled: Boolean = true,
|
||||
val outcomeBaselineShownAt: Long = 0L,
|
||||
val outcomeLastPromptedDay: String = ""
|
||||
)
|
||||
|
||||
interface SettingsRepository {
|
||||
|
|
@ -35,4 +38,7 @@ interface SettingsRepository {
|
|||
suspend fun setQuietHours(quietHours: QuietHours)
|
||||
suspend fun setOnboardingComplete(complete: Boolean)
|
||||
suspend fun setThemeMode(mode: ThemeMode)
|
||||
suspend fun setOutcomeReminderEnabled(enabled: Boolean)
|
||||
suspend fun markOutcomeBaselineShown()
|
||||
suspend fun setOutcomeLastPromptedDay(dayKey: String)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
package app.closer.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import app.closer.domain.model.OutcomeScores
|
||||
import app.closer.ui.settings.SettingsInk
|
||||
import app.closer.ui.settings.SettingsMuted
|
||||
import app.closer.ui.settings.SettingsOnPrimary
|
||||
import app.closer.ui.settings.SettingsPrimary
|
||||
import app.closer.ui.settings.SettingsPrimaryDeep
|
||||
import app.closer.ui.settings.SettingsSoft
|
||||
|
||||
/**
|
||||
* Manual test flow:
|
||||
* 1. Trigger dialog from pairing completion, Home banner, or Settings → Your Progress.
|
||||
* 2. Adjust sliders; confirm Submit is disabled until all four answered.
|
||||
* 3. Submit and verify callable invoked with correct scores.
|
||||
* 4. Skip closes dialog without submission.
|
||||
*/
|
||||
@Composable
|
||||
fun OutcomeCheckInDialog(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSubmit: (OutcomeScores) -> Unit
|
||||
) {
|
||||
var connection by remember { mutableIntStateOf(5) }
|
||||
var communication by remember { mutableIntStateOf(5) }
|
||||
var intimacy by remember { mutableIntStateOf(5) }
|
||||
var happiness by remember { mutableIntStateOf(5) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = SettingsSoft,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = SettingsInk,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = SettingsMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutcomeSlider(
|
||||
label = "How connected do you feel?",
|
||||
value = connection,
|
||||
onValueChange = { connection = it }
|
||||
)
|
||||
OutcomeSlider(
|
||||
label = "How well do you communicate?",
|
||||
value = communication,
|
||||
onValueChange = { communication = it }
|
||||
)
|
||||
OutcomeSlider(
|
||||
label = "How satisfied are you with intimacy?",
|
||||
value = intimacy,
|
||||
onValueChange = { intimacy = it }
|
||||
)
|
||||
OutcomeSlider(
|
||||
label = "How happy are you overall?",
|
||||
value = happiness,
|
||||
onValueChange = { happiness = it }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onSubmit(
|
||||
OutcomeScores(
|
||||
connection = connection,
|
||||
communication = communication,
|
||||
intimacy = intimacy,
|
||||
happiness = happiness
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = SettingsPrimary,
|
||||
contentColor = SettingsOnPrimary
|
||||
)
|
||||
) {
|
||||
Text("Submit", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Skip for now",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = SettingsPrimaryDeep
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutcomeSlider(
|
||||
label: String,
|
||||
value: Int,
|
||||
onValueChange: (Int) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = SettingsInk
|
||||
)
|
||||
Text(
|
||||
text = value.toString(),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = SettingsPrimaryDeep
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value.toFloat(),
|
||||
onValueChange = { onValueChange(it.toInt().coerceIn(1, 10)) },
|
||||
valueRange = 1f..10f,
|
||||
steps = 8,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("1", style = MaterialTheme.typography.labelSmall, color = SettingsMuted)
|
||||
Text("10", style = MaterialTheme.typography.labelSmall, color = SettingsMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
package app.closer.ui.outcomes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.TrendingUp
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import app.closer.domain.model.Outcome
|
||||
import app.closer.domain.model.OutcomeDay
|
||||
import app.closer.domain.model.OutcomeScores
|
||||
import app.closer.ui.components.ErrorState
|
||||
import app.closer.ui.settings.SettingsBackgroundBrush
|
||||
import app.closer.ui.settings.SettingsInk
|
||||
import app.closer.ui.settings.SettingsMuted
|
||||
import app.closer.ui.settings.SettingsPrimaryDeep
|
||||
import app.closer.ui.settings.SettingsSoft
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun YourProgressScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: YourProgressViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.load() }
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
modifier = Modifier.background(SettingsBackgroundBrush),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Your Progress", color = SettingsInk) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = SettingsInk
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
when {
|
||||
state.isLoading -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
state.error != null -> {
|
||||
ErrorState(
|
||||
title = "Couldn’t load progress",
|
||||
message = state.error ?: "",
|
||||
retryLabel = "Retry",
|
||||
onRetry = viewModel::load
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
ProgressHeader(state.baseline)
|
||||
state.baseline?.let { baseline ->
|
||||
state.latest?.let { latest ->
|
||||
DeltaCard(baseline = baseline, latest = latest)
|
||||
}
|
||||
}
|
||||
MilestoneList(state.outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressHeader(baseline: Outcome?) {
|
||||
val subtitle = if (baseline == null) {
|
||||
"Submit your first check-in to start tracking how your relationship feels over time."
|
||||
} else {
|
||||
"You’ve started tracking how your relationship feels. Check back at 30, 60, and 90 days to see what changes."
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = SettingsSoft)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.TrendingUp,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = SettingsPrimaryDeep
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = if (baseline == null) "No baseline yet" else "Baseline recorded",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = SettingsInk
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = SettingsMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeltaCard(baseline: Outcome, latest: Outcome) {
|
||||
val baselineScores = baseline.baseline ?: return
|
||||
val latestScores = latest.scores ?: return
|
||||
val delta = latest.delta ?: run {
|
||||
OutcomeScores(
|
||||
connection = latestScores.connection - baselineScores.connection,
|
||||
communication = latestScores.communication - baselineScores.communication,
|
||||
intimacy = latestScores.intimacy - baselineScores.intimacy,
|
||||
happiness = latestScores.happiness - baselineScores.happiness
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = SettingsSoft)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Change since baseline",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = SettingsInk
|
||||
)
|
||||
DeltaRow(label = "Connected", delta = delta.connection)
|
||||
DeltaRow(label = "Communication", delta = delta.communication)
|
||||
DeltaRow(label = "Intimacy", delta = delta.intimacy)
|
||||
DeltaRow(label = "Happiness", delta = delta.happiness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeltaRow(label: String, delta: Int) {
|
||||
val color = when {
|
||||
delta > 0 -> SettingsPrimaryDeep
|
||||
delta < 0 -> Color(0xFFB00020)
|
||||
else -> SettingsMuted
|
||||
}
|
||||
val sign = when {
|
||||
delta > 0 -> "+"
|
||||
else -> ""
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodyLarge, color = SettingsInk)
|
||||
Text(
|
||||
text = "$sign$delta",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = color,
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MilestoneList(outcomes: List<Outcome>) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "Milestones",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = SettingsInk
|
||||
)
|
||||
OutcomeDay.entries.forEach { day ->
|
||||
val outcome = outcomes.firstOrNull { it.dayKey == day.key }
|
||||
MilestoneRow(day = day, outcome = outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MilestoneRow(day: OutcomeDay, outcome: Outcome?) {
|
||||
val (title, status) = when (day) {
|
||||
OutcomeDay.BASELINE -> "Baseline" to if (outcome != null) "Recorded" else "Not yet"
|
||||
OutcomeDay.DAY_30 -> "30-day check-in" to if (outcome != null) "Recorded" else "Not yet"
|
||||
OutcomeDay.DAY_60 -> "60-day check-in" to if (outcome != null) "Recorded" else "Not yet"
|
||||
OutcomeDay.DAY_90 -> "90-day check-in" to if (outcome != null) "Recorded" else "Not yet"
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = SettingsInk)
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (outcome != null) SettingsPrimaryDeep else SettingsMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package app.closer.ui.outcomes
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.domain.model.Outcome
|
||||
import app.closer.domain.model.OutcomeDay
|
||||
import app.closer.domain.model.OutcomeDayKey
|
||||
import app.closer.domain.model.OutcomeScores
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.OutcomeRepository
|
||||
import app.closer.domain.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Manual test flow:
|
||||
* 1. Open Your Progress; verify empty baseline state.
|
||||
* 2. Submit baseline via OutcomeCheckInDialog; viewModel marks baseline shown and reloads.
|
||||
* 3. Submit day_30 outcome; verify delta card computes.
|
||||
* 4. Verify submitOutcome result reported to UI via snackbar/error.
|
||||
*/
|
||||
data class YourProgressUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val outcomes: List<Outcome> = emptyList(),
|
||||
val showBaselineDialog: Boolean = false,
|
||||
val showFollowUpDialog: Boolean = false,
|
||||
val pendingDayKey: OutcomeDayKey? = null,
|
||||
val submitSuccess: Boolean = false
|
||||
) {
|
||||
val baseline: Outcome? = outcomes.firstOrNull { it.dayKey == OutcomeDay.BASELINE.key }
|
||||
val latest: Outcome? = outcomes
|
||||
.filter { it.dayKey != OutcomeDay.BASELINE.key && it.scores != null }
|
||||
.maxByOrNull { OutcomeDay.fromKey(it.dayKey)?.ordinal ?: -1 }
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class YourProgressViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val outcomeRepository: OutcomeRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(YourProgressUiState())
|
||||
val uiState: StateFlow<YourProgressUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null, submitSuccess = false) }
|
||||
try {
|
||||
val userId = authRepository.currentUserId
|
||||
?: throw IllegalStateException("Not signed in.")
|
||||
val couple = coupleRepository.getCoupleForUser(userId)
|
||||
?: throw IllegalStateException("Not paired.")
|
||||
val outcomes = outcomeRepository.getOutcomes(couple.id)
|
||||
|
||||
val baselineRecorded = outcomes.any { it.dayKey == OutcomeDay.BASELINE.key }
|
||||
val promptDay = dueFollowUpDay(couple.createdAt, outcomes)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
outcomes = outcomes,
|
||||
showBaselineDialog = !baselineRecorded,
|
||||
showFollowUpDialog = promptDay != null,
|
||||
pendingDayKey = promptDay?.key
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn’t load progress.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitOutcome(dayKey: OutcomeDayKey, scores: OutcomeScores) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null, submitSuccess = false) }
|
||||
try {
|
||||
val userId = authRepository.currentUserId
|
||||
?: throw IllegalStateException("Not signed in.")
|
||||
val couple = coupleRepository.getCoupleForUser(userId)
|
||||
?: throw IllegalStateException("Not paired.")
|
||||
outcomeRepository.submitOutcome(couple.id, dayKey, scores).getOrThrow()
|
||||
|
||||
if (dayKey == OutcomeDay.BASELINE.key) {
|
||||
settingsRepository.markOutcomeBaselineShown()
|
||||
}
|
||||
settingsRepository.setOutcomeLastPromptedDay(dayKey)
|
||||
|
||||
_uiState.update { it.copy(submitSuccess = true) }
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn’t submit check-in.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissBaselineDialog() {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.markOutcomeBaselineShown()
|
||||
_uiState.update { it.copy(showBaselineDialog = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissFollowUpDialog() {
|
||||
_uiState.update { it.copy(showFollowUpDialog = false) }
|
||||
}
|
||||
|
||||
fun consumeSuccess() {
|
||||
_uiState.update { it.copy(submitSuccess = false) }
|
||||
}
|
||||
|
||||
fun consumeError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
private fun dueFollowUpDay(createdAt: Long, outcomes: List<Outcome>): OutcomeDay? {
|
||||
val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault())
|
||||
val today = LocalDate.now()
|
||||
val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today)
|
||||
|
||||
val due = listOf(
|
||||
OutcomeDay.DAY_30 to 30,
|
||||
OutcomeDay.DAY_60 to 60,
|
||||
OutcomeDay.DAY_90 to 90
|
||||
).firstOrNull { (_, days) -> ageDays >= days }
|
||||
?: return null
|
||||
|
||||
return if (outcomes.any { it.dayKey == due.first.key }) null else due.first
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
|||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.TrendingUp
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
|
|
@ -41,6 +42,8 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
|
|
@ -48,6 +51,9 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -60,6 +66,8 @@ import android.content.Context
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.core.navigation.ExternalLinks
|
||||
import app.closer.domain.model.OutcomeDay
|
||||
import app.closer.ui.components.OutcomeCheckInDialog
|
||||
import app.closer.ui.settings.SettingsDanger
|
||||
import app.closer.ui.settings.SettingsInk
|
||||
import app.closer.ui.settings.SettingsMuted
|
||||
|
|
@ -76,9 +84,11 @@ fun SettingsSubpage(
|
|||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = Color.Transparent,
|
||||
modifier = modifier.background(SettingsBackgroundBrush),
|
||||
topBar = {
|
||||
|
|
@ -172,12 +182,14 @@ fun SettingsScreen(
|
|||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(state.navigateTo) {
|
||||
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
containerColor = Color.Transparent,
|
||||
modifier = Modifier.background(SettingsBackgroundBrush),
|
||||
topBar = {
|
||||
|
|
@ -202,6 +214,67 @@ fun SettingsScreen(
|
|||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
var showBaselineOutcomeDialog by remember { mutableStateOf(false) }
|
||||
var showFollowUpOutcomeDialog by remember { mutableStateOf(false) }
|
||||
var pendingOutcomeDay by remember { mutableStateOf<OutcomeDay?>(null) }
|
||||
|
||||
val followUp = state.outcomeFollowUpDay
|
||||
LaunchedEffect(state.isPaired, followUp) {
|
||||
if (state.isPaired) {
|
||||
if (state.outcomeBaselineDialogDue) {
|
||||
showBaselineOutcomeDialog = true
|
||||
}
|
||||
if (followUp != null) {
|
||||
pendingOutcomeDay = followUp
|
||||
showFollowUpOutcomeDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBaselineOutcomeDialog) {
|
||||
OutcomeCheckInDialog(
|
||||
title = "Quick check-in",
|
||||
subtitle = "Before you start, how are you feeling about your relationship right now?",
|
||||
onDismiss = {
|
||||
viewModel.markBaselineOutcomeShown()
|
||||
showBaselineOutcomeDialog = false
|
||||
},
|
||||
onSubmit = { scores ->
|
||||
viewModel.submitOutcome(OutcomeDay.BASELINE.key, scores)
|
||||
showBaselineOutcomeDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pendingOutcomeDay?.let { day ->
|
||||
if (showFollowUpOutcomeDialog) {
|
||||
OutcomeCheckInDialog(
|
||||
title = "${day.label} check-in",
|
||||
subtitle = "How are you feeling now compared to when you started?",
|
||||
onDismiss = {
|
||||
viewModel.markFollowUpOutcomeShown(day.key)
|
||||
showFollowUpOutcomeDialog = false
|
||||
pendingOutcomeDay = null
|
||||
},
|
||||
onSubmit = { scores ->
|
||||
viewModel.submitOutcome(day.key, scores)
|
||||
showFollowUpOutcomeDialog = false
|
||||
pendingOutcomeDay = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.outcomeSubmitSuccess) {
|
||||
if (state.outcomeSubmitSuccess) {
|
||||
snackbar.showSnackbar("Check-in saved")
|
||||
viewModel.consumeOutcomeSuccess()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.outcomeError) {
|
||||
state.outcomeError?.let { snackbar.showSnackbar(it); viewModel.consumeOutcomeError() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -338,6 +411,12 @@ fun SettingsScreen(
|
|||
onClick = { onNavigate(AppRoute.ANSWER_HISTORY) }
|
||||
)
|
||||
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.TrendingUp,
|
||||
label = "Your Progress",
|
||||
onClick = { onNavigate(AppRoute.YOUR_PROGRESS) }
|
||||
)
|
||||
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Palette,
|
||||
label = "Appearance",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import android.util.Log
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.model.Outcome
|
||||
import app.closer.domain.model.OutcomeDay
|
||||
import app.closer.domain.model.OutcomeDayKey
|
||||
import app.closer.domain.model.OutcomeScores
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.OutcomeRepository
|
||||
import app.closer.domain.repository.SettingsRepository
|
||||
import app.closer.domain.repository.ThemeMode
|
||||
import app.closer.domain.repository.UserRepository
|
||||
|
|
@ -13,8 +18,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
|
|
@ -25,7 +34,11 @@ data class SettingsUiState(
|
|||
val isPaired: Boolean = false,
|
||||
val isSigningOut: Boolean = false,
|
||||
val themeMode: ThemeMode = ThemeMode.DEVICE,
|
||||
val navigateTo: String? = null
|
||||
val navigateTo: String? = null,
|
||||
val outcomeBaselineDialogDue: Boolean = false,
|
||||
val outcomeFollowUpDay: OutcomeDay? = null,
|
||||
val outcomeSubmitSuccess: Boolean = false,
|
||||
val outcomeError: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -33,7 +46,8 @@ class SettingsViewModel @Inject constructor(
|
|||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val outcomeRepository: OutcomeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
|
|
@ -74,18 +88,87 @@ class SettingsViewModel @Inject constructor(
|
|||
.onFailure { e -> Log.w(TAG, "Could not load partner display name", e) }
|
||||
.getOrNull()
|
||||
}
|
||||
val outcomes = couple?.let {
|
||||
runCatching { outcomeRepository.getOutcomes(it.id) }
|
||||
.onFailure { Log.w(TAG, "Could not load outcomes", it) }
|
||||
.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
val baselineShownAt = settingsRepository.settings.first().outcomeBaselineShownAt
|
||||
val outcomeBaselineDialogDue = couple != null &&
|
||||
outcomes.none { it.dayKey == OutcomeDay.BASELINE.key } &&
|
||||
baselineShownAt == 0L
|
||||
val followUpDay = couple?.let { dueFollowUpDay(it.createdAt, outcomes) }
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
displayName = user?.displayName ?: "",
|
||||
email = email,
|
||||
partnerName = partnerName,
|
||||
isPaired = couple != null
|
||||
isPaired = couple != null,
|
||||
outcomeBaselineDialogDue = outcomeBaselineDialogDue,
|
||||
outcomeFollowUpDay = followUpDay
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitOutcome(dayKey: OutcomeDayKey, scores: OutcomeScores) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(outcomeSubmitSuccess = false, outcomeError = null) }
|
||||
try {
|
||||
val userId = authRepository.currentUserId
|
||||
?: throw IllegalStateException("Not signed in.")
|
||||
val couple = coupleRepository.getCoupleForUser(userId)
|
||||
?: throw IllegalStateException("Not paired.")
|
||||
outcomeRepository.submitOutcome(couple.id, dayKey, scores).getOrThrow()
|
||||
settingsRepository.setOutcomeLastPromptedDay(dayKey)
|
||||
if (dayKey == OutcomeDay.BASELINE.key) {
|
||||
settingsRepository.markOutcomeBaselineShown()
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
outcomeSubmitSuccess = true,
|
||||
outcomeBaselineDialogDue = false,
|
||||
outcomeFollowUpDay = null
|
||||
)
|
||||
}
|
||||
loadSettings()
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(outcomeError = e.message ?: "Couldn’t save check-in.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markBaselineOutcomeShown() {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.markOutcomeBaselineShown()
|
||||
_uiState.update { it.copy(outcomeBaselineDialogDue = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun markFollowUpOutcomeShown(dayKey: OutcomeDayKey) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setOutcomeLastPromptedDay(dayKey)
|
||||
_uiState.update { it.copy(outcomeFollowUpDay = null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeOutcomeSuccess() = _uiState.update { it.copy(outcomeSubmitSuccess = false) }
|
||||
fun consumeOutcomeError() = _uiState.update { it.copy(outcomeError = null) }
|
||||
|
||||
private fun dueFollowUpDay(createdAt: Long, outcomes: List<Outcome>): OutcomeDay? {
|
||||
val paired = LocalDate.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault())
|
||||
val today = LocalDate.now()
|
||||
val ageDays = java.time.temporal.ChronoUnit.DAYS.between(paired, today)
|
||||
val due = listOf(
|
||||
OutcomeDay.DAY_30 to 30,
|
||||
OutcomeDay.DAY_60 to 60,
|
||||
OutcomeDay.DAY_90 to 90
|
||||
).firstOrNull { (_, days) -> ageDays >= days } ?: return null
|
||||
return if (outcomes.any { it.dayKey == due.first.key }) null else due.first
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
_uiState.update { it.copy(isSigningOut = true) }
|
||||
viewModelScope.launch {
|
||||
|
|
|
|||
|
|
@ -206,6 +206,14 @@ service cloud.firestore {
|
|||
allow read, write: if false;
|
||||
}
|
||||
|
||||
// Per-user outcome mirrors for cross-relationship progress stats.
|
||||
// Writes are server-side only (submitOutcomeCallable); direct client writes denied.
|
||||
match /outcomes/{dayKey} {
|
||||
allow read: if isOwner(uid)
|
||||
&& dayKey in ['day_0', 'day_30', 'day_60', 'day_90'];
|
||||
allow create, update, delete: if false;
|
||||
}
|
||||
|
||||
// FCM registration tokens: owner can read/write their own tokens.
|
||||
match /fcmTokens/{tokenId} {
|
||||
allow read, write: if isOwner(uid);
|
||||
|
|
@ -521,6 +529,14 @@ service cloud.firestore {
|
|||
allow delete: if isCouplesMember(coupleId);
|
||||
}
|
||||
|
||||
// Outcomes: couple-level 30/60/90 day check-ins. Both members can read.
|
||||
// Writes are server-side only via submitOutcomeCallable; direct client writes denied.
|
||||
match /outcomes/{dayKey} {
|
||||
allow read: if isCouplesMember(coupleId)
|
||||
&& dayKey in ['day_0', 'day_30', 'day_60', 'day_90'];
|
||||
allow create, update, delete: if false;
|
||||
}
|
||||
|
||||
// Daily question: server-assigned once per day per couple.
|
||||
// Writes are server-only (Cloud Functions / Admin SDK).
|
||||
match /daily_question/{date} {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
|||
inviteCode: code,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
streakCount: 0,
|
||||
// encryptionVersion must stay in sync with Android's
|
||||
// app/src/main/java/app/closer/crypto/EncryptionVersion.kt.
|
||||
// v0 = plaintext (iOS MVP, no E2EE); v1 = legacy migration;
|
||||
// v2 = strict E2EE — the default for all new Android couples.
|
||||
encryptionVersion: 2,
|
||||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createInviteCallable = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
/**
|
||||
* HTTPS callable that creates a secure invite code.
|
||||
*
|
||||
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
|
||||
* invites directly. 6-character document IDs are enumerable, so a direct client
|
||||
* write would expose pending invites to scanning. This function generates a
|
||||
* unique 6-character code server-side, stores the invite document, and returns
|
||||
* only the code and expiry to the inviter.
|
||||
*
|
||||
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string }
|
||||
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
||||
* - kdfSalt: base64 KDF salt
|
||||
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
||||
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor)
|
||||
*
|
||||
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
||||
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
||||
* supply these fields. Android always supplies them.
|
||||
*
|
||||
* Response: { code: string, expiresAt: Timestamp }
|
||||
*
|
||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||
* 1. Verify caller is authenticated and not already paired.
|
||||
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
|
||||
* 3. Generate a unique 6-character alphanumeric code via transaction.
|
||||
* 4. Write the invite document with a 24-hour TTL.
|
||||
*/
|
||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 6;
|
||||
const INVITE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
function generateCode() {
|
||||
let code = '';
|
||||
const randomValues = Buffer.alloc(CODE_LENGTH);
|
||||
// crypto.randomBytes is synchronous and suitable for Cloud Functions.
|
||||
require('crypto').randomFillSync(randomValues);
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_CHARS[randomValues[i] % CODE_CHARS.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
||||
var _a, _b, _c;
|
||||
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
|
||||
if (!callerId) {
|
||||
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.');
|
||||
}
|
||||
const db = admin.firestore();
|
||||
// Caller must not already be paired.
|
||||
const callerDoc = await db.collection('users').doc(callerId).get();
|
||||
if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) {
|
||||
throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.');
|
||||
}
|
||||
const callerDisplayName = (_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.displayName;
|
||||
// Rate limit: count invites created by this user in the last hour.
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const windowStart = admin.firestore.Timestamp.fromMillis(now.toMillis() - RATE_LIMIT_WINDOW_MS);
|
||||
const recentInvitesQuery = db
|
||||
.collection('invites')
|
||||
.where('inviterUserId', '==', callerId)
|
||||
.where('createdAt', '>=', windowStart)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(RATE_LIMIT_MAX + 1);
|
||||
const recentInvites = await recentInvitesQuery.get();
|
||||
if (recentInvites.size >= RATE_LIMIT_MAX) {
|
||||
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
|
||||
}
|
||||
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
|
||||
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
|
||||
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
|
||||
const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase;
|
||||
// E2EE fields must be supplied together or omitted together.
|
||||
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams];
|
||||
const suppliedE2ee = e2eeFields.filter((v) => v != null).length;
|
||||
if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.');
|
||||
}
|
||||
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
|
||||
// Race-safe unique code creation via transaction. We attempt a bounded number
|
||||
// of times; each attempt verifies the candidate code is free before creating.
|
||||
const maxAttempts = 10;
|
||||
let inviteRef = null;
|
||||
let code = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const candidate = generateCode();
|
||||
const candidateRef = db.collection('invites').doc(candidate);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const created = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(candidateRef);
|
||||
if (snap.exists) {
|
||||
return false;
|
||||
}
|
||||
tx.set(candidateRef, {
|
||||
code: candidate,
|
||||
inviterUserId: callerId,
|
||||
inviterDisplayName: callerDisplayName !== null && callerDisplayName !== void 0 ? callerDisplayName : null,
|
||||
status: 'pending',
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
expiresAt,
|
||||
usedAt: null,
|
||||
usedByUserId: null,
|
||||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
||||
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (created) {
|
||||
code = candidate;
|
||||
inviteRef = candidateRef;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!code || !inviteRef) {
|
||||
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.');
|
||||
}
|
||||
// Write a server-side audit log entry for the inviter. This is not read by
|
||||
// clients and supports the rate-limit count as well as future abuse review.
|
||||
try {
|
||||
await db.collection('users').doc(callerId).collection('notification_queue').add({
|
||||
type: 'invite_created',
|
||||
inviteCode: code,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
read: true,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
// Audit write is best-effort; do not fail the invite if it errors.
|
||||
console.warn(`[createInviteCallable] audit log failed for ${callerId}:`, err);
|
||||
}
|
||||
console.log(`[createInviteCallable] ${callerId} created invite ${code}; expires ${expiresAt.toDate().toISOString()}`);
|
||||
return { code, expiresAt };
|
||||
});
|
||||
//# sourceMappingURL=createInviteCallable.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.scheduledOutcomesReminder = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const REMINDER_DAYS = [30, 60, 90];
|
||||
const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' };
|
||||
exports.scheduledOutcomesReminder = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
var _a, _b, _c;
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const now = Date.now();
|
||||
const couplesSnap = await db.collection('couples').limit(200).get();
|
||||
const notifications = [];
|
||||
for (const coupleDoc of couplesSnap.docs) {
|
||||
const coupleId = coupleDoc.id;
|
||||
const data = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {};
|
||||
const createdAt = Number((_b = data.createdAt) !== null && _b !== void 0 ? _b : 0);
|
||||
if (createdAt <= 0)
|
||||
continue;
|
||||
const ageDays = Math.floor((now - createdAt) / DAY_MS);
|
||||
const dueDays = REMINDER_DAYS.filter((day) => ageDays >= day && ageDays <= day + 2);
|
||||
if (dueDays.length === 0)
|
||||
continue;
|
||||
const userIds = ((_c = data.userIds) !== null && _c !== void 0 ? _c : []);
|
||||
if (userIds.length === 0)
|
||||
continue;
|
||||
// Check each due checkpoint; only remind for the first one without an outcome.
|
||||
let remindedDay = null;
|
||||
for (const day of dueDays) {
|
||||
const dayKey = DAY_KEY_MAP[day];
|
||||
const outcomeSnap = await coupleDoc.ref.collection('outcomes').doc(dayKey).get();
|
||||
if (!outcomeSnap.exists) {
|
||||
remindedDay = day;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (remindedDay == null)
|
||||
continue;
|
||||
const dayLabel = remindedDay;
|
||||
const title = 'How are you feeling together?';
|
||||
const body = `You’ve been connected for ${dayLabel} days. Take a quick check-in to see how things have changed.`;
|
||||
for (const userId of userIds) {
|
||||
notifications.push({ userId, coupleId, day: dayLabel, title, body });
|
||||
}
|
||||
}
|
||||
await Promise.all(notifications.map((notification) => sendOutcomeReminder(db, messaging, notification)));
|
||||
console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`);
|
||||
});
|
||||
async function sendOutcomeReminder(db, messaging, notification) {
|
||||
await db
|
||||
.collection('users')
|
||||
.doc(notification.userId)
|
||||
.collection('notification_queue')
|
||||
.add({
|
||||
type: 'outcome_reminder',
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
coupleId: notification.coupleId,
|
||||
day: notification.day,
|
||||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const tokens = await getUserTokens(db, notification.userId);
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`);
|
||||
return;
|
||||
}
|
||||
const message = {
|
||||
token: tokens[0],
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
},
|
||||
data: {
|
||||
type: 'outcome_reminder',
|
||||
coupleId: notification.coupleId,
|
||||
day: String(notification.day),
|
||||
},
|
||||
};
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token }))));
|
||||
sendResults.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn(`[sendOutcomeReminder] FCM send to ${tokens[index]} failed:`, result.reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
async function getUserTokens(db, userId) {
|
||||
var _a;
|
||||
const tokens = [];
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
|
||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||
tokens.push(legacyToken);
|
||||
}
|
||||
const tokenSnapshot = await db
|
||||
.collection('users')
|
||||
.doc(userId)
|
||||
.collection('fcmTokens')
|
||||
.get();
|
||||
tokenSnapshot.docs.forEach((doc) => {
|
||||
var _a;
|
||||
const token = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
|
||||
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
|
||||
tokens.push(token);
|
||||
}
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
//# sourceMappingURL=scheduledOutcomesReminder.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"scheduledOutcomesReminder.js","sourceRoot":"","sources":["../../src/couples/scheduledOutcomesReminder.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.submitOutcomeCallable = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const DAY_KEYS = ['day_0', 'day_30', 'day_60', 'day_90'];
|
||||
const SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness'];
|
||||
const MIN_SCORE = 1;
|
||||
const MAX_SCORE = 10;
|
||||
function isValidDayKey(value) {
|
||||
return typeof value === 'string' && DAY_KEYS.includes(value);
|
||||
}
|
||||
function isValidScoreMap(value) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
return false;
|
||||
const map = value;
|
||||
for (const key of SCORE_KEYS) {
|
||||
const num = map[key];
|
||||
if (typeof num !== 'number' || Number.isNaN(num))
|
||||
return false;
|
||||
if (num < MIN_SCORE || num > MAX_SCORE)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
exports.submitOutcomeCallable = functions.https.onCall(async (data, context) => {
|
||||
var _a, _b, _c;
|
||||
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
|
||||
if (!callerId) {
|
||||
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.');
|
||||
}
|
||||
const coupleId = data === null || data === void 0 ? void 0 : data.coupleId;
|
||||
if (typeof coupleId !== 'string' || coupleId.length === 0) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.');
|
||||
}
|
||||
const dayKey = data === null || data === void 0 ? void 0 : data.dayKey;
|
||||
if (!isValidDayKey(dayKey)) {
|
||||
throw new functions.https.HttpsError('invalid-argument', `dayKey must be one of: ${DAY_KEYS.join(', ')}.`);
|
||||
}
|
||||
const scores = data === null || data === void 0 ? void 0 : data.scores;
|
||||
if (!isValidScoreMap(scores)) {
|
||||
throw new functions.https.HttpsError('invalid-argument', `scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.`);
|
||||
}
|
||||
const db = admin.firestore();
|
||||
const coupleRef = db.collection('couples').doc(coupleId);
|
||||
// Caller must be a member of the couple.
|
||||
const coupleDoc = await coupleRef.get();
|
||||
if (!coupleDoc.exists) {
|
||||
throw new functions.https.HttpsError('not-found', 'Couple not found.');
|
||||
}
|
||||
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
|
||||
if (!userIds.includes(callerId)) {
|
||||
throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.');
|
||||
}
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const outcomeRef = coupleRef.collection('outcomes').doc(dayKey);
|
||||
const result = await db.runTransaction(async (tx) => {
|
||||
var _a, _b, _c, _d, _e, _f, _g, _h;
|
||||
const existing = await tx.get(outcomeRef);
|
||||
const existingData = existing.exists ? ((_a = existing.data()) !== null && _a !== void 0 ? _a : {}) : {};
|
||||
// If this is a follow-up (non-baseline), a baseline must exist to compute delta.
|
||||
if (dayKey !== 'day_0') {
|
||||
const baselineRef = coupleRef.collection('outcomes').doc('day_0');
|
||||
const baselineSnap = await tx.get(baselineRef);
|
||||
if (!baselineSnap.exists) {
|
||||
throw new functions.https.HttpsError('failed-precondition', 'Baseline (day_0) outcome must be submitted before follow-up outcomes.');
|
||||
}
|
||||
}
|
||||
const answeredBy = ((_b = existingData.answeredBy) !== null && _b !== void 0 ? _b : []);
|
||||
if (!answeredBy.includes(callerId)) {
|
||||
answeredBy.push(callerId);
|
||||
}
|
||||
const payload = {
|
||||
dayKey,
|
||||
answeredBy,
|
||||
updatedAt: now,
|
||||
createdAt: (_c = existingData.createdAt) !== null && _c !== void 0 ? _c : now,
|
||||
};
|
||||
if (dayKey === 'day_0') {
|
||||
payload.baseline = scores;
|
||||
}
|
||||
else {
|
||||
payload.scores = scores;
|
||||
}
|
||||
// Delta is recalculated every time so repeated submissions stay consistent.
|
||||
if (dayKey !== 'day_0') {
|
||||
const baselineRef = coupleRef.collection('outcomes').doc('day_0');
|
||||
const baselineSnap = await tx.get(baselineRef);
|
||||
if (baselineSnap.exists) {
|
||||
const baseline = ((_e = (_d = baselineSnap.data()) === null || _d === void 0 ? void 0 : _d.baseline) !== null && _e !== void 0 ? _e : {});
|
||||
const delta = {};
|
||||
for (const key of SCORE_KEYS) {
|
||||
delta[key] = ((_f = scores[key]) !== null && _f !== void 0 ? _f : 0) - ((_g = baseline[key]) !== null && _g !== void 0 ? _g : 0);
|
||||
}
|
||||
payload.delta = delta;
|
||||
}
|
||||
}
|
||||
tx.set(outcomeRef, payload, { merge: true });
|
||||
// Per-user mirror for cross-relationship stats.
|
||||
const userOutcomeRef = db.collection('users').doc(callerId).collection('outcomes').doc(dayKey);
|
||||
tx.set(userOutcomeRef, Object.assign(Object.assign({ dayKey,
|
||||
coupleId }, (dayKey === 'day_0' ? { baseline: scores } : { scores, delta: payload.delta })), { answeredBy: [callerId], createdAt: (_h = existingData.createdAt) !== null && _h !== void 0 ? _h : now, updatedAt: now }), { merge: true });
|
||||
return { dayKey, answeredBy };
|
||||
});
|
||||
console.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`);
|
||||
return Object.assign({ success: true }, result);
|
||||
});
|
||||
//# sourceMappingURL=submitOutcomeCallable.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"submitOutcomeCallable.js","sourceRoot":"","sources":["../../src/couples/submitOutcomeCallable.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
|
||||
exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
// Initialize the Admin SDK once for every function in this codebase.
|
||||
|
|
@ -71,6 +71,12 @@ var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable");
|
|||
Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } });
|
||||
var acceptInviteCallable_1 = require("./couples/acceptInviteCallable");
|
||||
Object.defineProperty(exports, "acceptInviteCallable", { enumerable: true, get: function () { return acceptInviteCallable_1.acceptInviteCallable; } });
|
||||
var createInviteCallable_1 = require("./couples/createInviteCallable");
|
||||
Object.defineProperty(exports, "createInviteCallable", { enumerable: true, get: function () { return createInviteCallable_1.createInviteCallable; } });
|
||||
var submitOutcomeCallable_1 = require("./couples/submitOutcomeCallable");
|
||||
Object.defineProperty(exports, "submitOutcomeCallable", { enumerable: true, get: function () { return submitOutcomeCallable_1.submitOutcomeCallable; } });
|
||||
var scheduledOutcomesReminder_1 = require("./couples/scheduledOutcomesReminder");
|
||||
Object.defineProperty(exports, "scheduledOutcomesReminder", { enumerable: true, get: function () { return scheduledOutcomesReminder_1.scheduledOutcomesReminder; } });
|
||||
var onUserDelete_1 = require("./users/onUserDelete");
|
||||
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
|
||||
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"}
|
||||
|
|
@ -36,13 +36,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||
exports.sendGentleReminderCallable = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const GENTLE_REMINDER_MAX_PER_HOUR = 5;
|
||||
const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
/**
|
||||
* Sends a gentle nudge from one partner to the other when the caller has
|
||||
* already answered today's question but the partner hasn't.
|
||||
*
|
||||
* Rate limit: one reminder per couple per calendar day (UTC). The lock is
|
||||
* stored in couples/{coupleId}/gentle_reminders/{date} so it survives
|
||||
* function restarts and is visible to both partners.
|
||||
* Rate limits:
|
||||
* - Per-user: max 5 gentle reminders per rolling hour. Guarded by a server-side
|
||||
* transaction on `rate_limits/{uid}_gentle_reminder` so malicious clients
|
||||
* cannot bypass it by calling the callable in a loop. The Android-side
|
||||
* NotificationRateLimiter remains for UX but is not the authoritative guard.
|
||||
* - Per-couple: one reminder per couple per calendar day (UTC). The lock is
|
||||
* stored in couples/{coupleId}/gentle_reminders/{date} so it survives
|
||||
* function restarts and is visible to both partners.
|
||||
*
|
||||
* The notification is both an FCM push (for the system tray) and an entry in
|
||||
* the partner's notification_queue (for in-app display).
|
||||
|
|
@ -69,7 +76,44 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
|
|||
if (!partnerId) {
|
||||
throw new functions.https.HttpsError('failed-precondition', 'No partner found.');
|
||||
}
|
||||
// ── 2. Rate limit: one per couple per day ────────────────────────────────
|
||||
// ── 2. Server-side per-user throttle: 5 per hour (rolling window) ────────
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`);
|
||||
const throttleResult = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(rateLimitRef);
|
||||
const data = snap.data();
|
||||
let windowStart;
|
||||
let count;
|
||||
if (!snap.exists || !data) {
|
||||
windowStart = now;
|
||||
count = 0;
|
||||
}
|
||||
else {
|
||||
windowStart = data.windowStart;
|
||||
count = (typeof data.count === 'number' ? data.count : 0);
|
||||
const elapsedMs = now.toMillis() - windowStart.toMillis();
|
||||
if (elapsedMs >= GENTLE_REMINDER_WINDOW_MS) {
|
||||
// Rolling window has expired; start a fresh one.
|
||||
windowStart = now;
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
if (count >= GENTLE_REMINDER_MAX_PER_HOUR) {
|
||||
const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis());
|
||||
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60000));
|
||||
return { allowed: false, retryAfterMinutes };
|
||||
}
|
||||
tx.set(rateLimitRef, {
|
||||
count: count + 1,
|
||||
windowStart,
|
||||
updatedAt: now,
|
||||
}, { merge: true });
|
||||
return { allowed: true, count: count + 1, windowStart };
|
||||
});
|
||||
if (!throttleResult.allowed) {
|
||||
throw new functions.https.HttpsError('resource-exhausted', `Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`);
|
||||
}
|
||||
// ── 3. Rate limit: one per couple per day ────────────────────────────────
|
||||
const today = new Date().toISOString().slice(0, 10); // e.g. "2026-06-19"
|
||||
const lockRef = db
|
||||
.collection('couples')
|
||||
|
|
@ -80,7 +124,7 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
|
|||
if (existingLock.exists) {
|
||||
return { sent: false, reason: 'already_sent_today' };
|
||||
}
|
||||
// ── 3. Collect partner FCM tokens ────────────────────────────────────────
|
||||
// ── 4. Collect partner FCM tokens ────────────────────────────────────────
|
||||
const tokens = [];
|
||||
const partnerDoc = await db.collection('users').doc(partnerId).get();
|
||||
if (partnerDoc.exists) {
|
||||
|
|
@ -101,7 +145,7 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
|
|||
tokens.push(t);
|
||||
}
|
||||
});
|
||||
// ── 4. Write in-app notification record ──────────────────────────────────
|
||||
// ── 5. Write in-app notification record ──────────────────────────────────
|
||||
await db
|
||||
.collection('users')
|
||||
.doc(partnerId)
|
||||
|
|
@ -114,12 +158,12 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
|
|||
sent: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
// ── 5. Claim the daily rate-limit lock ───────────────────────────────────
|
||||
// ── 6. Claim the daily rate-limit lock ───────────────────────────────────
|
||||
await lockRef.set({
|
||||
sentBy: callerId,
|
||||
sentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
// ── 6. Send FCM push ─────────────────────────────────────────────────────
|
||||
// ── 7. Send FCM push ─────────────────────────────────────────────────────
|
||||
if (tokens.length > 0) {
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send({
|
||||
token,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,157 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
/**
|
||||
* Cloud Function: scheduledOutcomesReminder
|
||||
*
|
||||
* Manual test flow:
|
||||
* 1. Create a test couple with createdAt = ~31 days ago.
|
||||
* 2. Ensure no day_0/day_30 outcomes exist (or only day_0 exists to test day_30).
|
||||
* 3. Use the Firebase Functions shell or temporarily change the schedule to test.
|
||||
* 4. Verify FCM notification sent and notification_queue records written.
|
||||
* 5. Verify day_30 not sent if day_30 outcome already exists.
|
||||
*
|
||||
* Cron: every 24 hours. Iterates couples and nudges both members when a 30/60/90
|
||||
* day outcome is due and has not yet been submitted.
|
||||
*/
|
||||
|
||||
export type OutcomeDayKey = 'day_0' | 'day_30' | 'day_60' | 'day_90'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const REMINDER_DAYS = [30, 60, 90] as const
|
||||
const DAY_KEY_MAP: Record<number, OutcomeDayKey> = { 30: 'day_30', 60: 'day_60', 90: 'day_90' }
|
||||
|
||||
export const scheduledOutcomesReminder = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
||||
const couplesSnap = await db.collection('couples').limit(200).get()
|
||||
const notifications: {
|
||||
userId: string
|
||||
coupleId: string
|
||||
day: number
|
||||
title: string
|
||||
body: string
|
||||
}[] = []
|
||||
|
||||
for (const coupleDoc of couplesSnap.docs) {
|
||||
const coupleId = coupleDoc.id
|
||||
const data = coupleDoc.data() ?? {}
|
||||
const createdAt = Number(data.createdAt ?? 0)
|
||||
if (createdAt <= 0) continue
|
||||
|
||||
const ageDays = Math.floor((now - createdAt) / DAY_MS)
|
||||
const dueDays = REMINDER_DAYS.filter((day) => ageDays >= day && ageDays <= day + 2)
|
||||
if (dueDays.length === 0) continue
|
||||
|
||||
const userIds = (data.userIds ?? []) as string[]
|
||||
if (userIds.length === 0) continue
|
||||
|
||||
// Check each due checkpoint; only remind for the first one without an outcome.
|
||||
let remindedDay: number | null = null
|
||||
for (const day of dueDays) {
|
||||
const dayKey = DAY_KEY_MAP[day]
|
||||
const outcomeSnap = await coupleDoc.ref.collection('outcomes').doc(dayKey).get()
|
||||
if (!outcomeSnap.exists) {
|
||||
remindedDay = day
|
||||
break
|
||||
}
|
||||
}
|
||||
if (remindedDay == null) continue
|
||||
|
||||
const dayLabel = remindedDay
|
||||
const title = 'How are you feeling together?'
|
||||
const body = `You’ve been connected for ${dayLabel} days. Take a quick check-in to see how things have changed.`
|
||||
|
||||
for (const userId of userIds) {
|
||||
notifications.push({ userId, coupleId, day: dayLabel, title, body })
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
notifications.map((notification) =>
|
||||
sendOutcomeReminder(db, messaging, notification)
|
||||
)
|
||||
)
|
||||
|
||||
console.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`)
|
||||
})
|
||||
|
||||
async function sendOutcomeReminder(
|
||||
db: admin.firestore.Firestore,
|
||||
messaging: admin.messaging.Messaging,
|
||||
notification: { userId: string; coupleId: string; day: number; title: string; body: string }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.collection('users')
|
||||
.doc(notification.userId)
|
||||
.collection('notification_queue')
|
||||
.add({
|
||||
type: 'outcome_reminder',
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
coupleId: notification.coupleId,
|
||||
day: notification.day,
|
||||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const tokens = await getUserTokens(db, notification.userId)
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendOutcomeReminder] no FCM tokens for ${notification.userId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const message: admin.messaging.Message = {
|
||||
token: tokens[0],
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
},
|
||||
data: {
|
||||
type: 'outcome_reminder',
|
||||
coupleId: notification.coupleId,
|
||||
day: String(notification.day),
|
||||
},
|
||||
}
|
||||
|
||||
const sendResults = await Promise.allSettled(
|
||||
tokens.map((token) => messaging.send({ ...message, token }))
|
||||
)
|
||||
|
||||
sendResults.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn(
|
||||
`[sendOutcomeReminder] FCM send to ${tokens[index]} failed:`,
|
||||
result.reason
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function getUserTokens(db: admin.firestore.Firestore, userId: string): Promise<string[]> {
|
||||
const tokens: string[] = []
|
||||
const userDoc = await db.collection('users').doc(userId).get()
|
||||
const legacyToken = userDoc.data()?.fcmToken
|
||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||
tokens.push(legacyToken)
|
||||
}
|
||||
|
||||
const tokenSnapshot = await db
|
||||
.collection('users')
|
||||
.doc(userId)
|
||||
.collection('fcmTokens')
|
||||
.get()
|
||||
|
||||
tokenSnapshot.docs.forEach((doc) => {
|
||||
const token = doc.data()?.token
|
||||
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
|
||||
tokens.push(token)
|
||||
}
|
||||
})
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
/**
|
||||
* Cloud Function: submitOutcomeCallable
|
||||
*
|
||||
* Manual test flow:
|
||||
* 1. Pair two test users via acceptInviteCallable.
|
||||
* 2. From one member, call submitOutcomeCallable with:
|
||||
* { dayKey: "day_0", scores: { connection: 7, communication: 6, intimacy: 8, happiness: 9 } }
|
||||
* 3. Verify Firestore doc couples/{coupleId}/outcomes/day_0 exists with the payload.
|
||||
* 4. Verify users/{callerUid}/outcomes/day_0 mirror doc exists.
|
||||
* 5. Submit from the partner; verify answeredBy contains both UIDs.
|
||||
* 6. Try invalid inputs (day_30 without day_0, scores out of range, non-member) and confirm rejection.
|
||||
*
|
||||
* Auth required; caller must be a member of the couple. Writes are performed by the
|
||||
* Admin SDK so they bypass Firestore rules — direct client writes to the outcomes
|
||||
* subcollection are denied in firestore.rules.
|
||||
*
|
||||
* Schema for couples/{coupleId}/outcomes/{dayKey}:
|
||||
* dayKey "day_0" | "day_30" | "day_60" | "day_90"
|
||||
* baseline { connection, communication, intimacy, happiness } (day_0 only)
|
||||
* scores { connection, communication, intimacy, happiness } (day_30/60/90 only)
|
||||
* delta { connection, communication, intimacy, happiness } (day_30/60/90 only)
|
||||
* answeredBy [uid1, uid2] — deduplicated, appends on each call
|
||||
* createdAt server timestamp
|
||||
* updatedAt server timestamp
|
||||
*
|
||||
* Per-user mirror at users/{uid}/outcomes/{dayKey}:
|
||||
* dayKey, baseline/scores, delta, createdAt, updatedAt
|
||||
* This supports "your progress across all relationships" later.
|
||||
*/
|
||||
|
||||
export type OutcomeDayKey = 'day_0' | 'day_30' | 'day_60' | 'day_90'
|
||||
export type ScoreKey = 'connection' | 'communication' | 'intimacy' | 'happiness'
|
||||
|
||||
const DAY_KEYS: OutcomeDayKey[] = ['day_0', 'day_30', 'day_60', 'day_90']
|
||||
const SCORE_KEYS: ScoreKey[] = ['connection', 'communication', 'intimacy', 'happiness']
|
||||
const MIN_SCORE = 1
|
||||
const MAX_SCORE = 10
|
||||
|
||||
function isValidDayKey(value: unknown): value is OutcomeDayKey {
|
||||
return typeof value === 'string' && (DAY_KEYS as string[]).includes(value)
|
||||
}
|
||||
|
||||
function isValidScoreMap(value: unknown): value is Record<ScoreKey, number> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const map = value as Record<string, unknown>
|
||||
for (const key of SCORE_KEYS) {
|
||||
const num = map[key]
|
||||
if (typeof num !== 'number' || Number.isNaN(num)) return false
|
||||
if (num < MIN_SCORE || num > MAX_SCORE) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const submitOutcomeCallable = functions.https.onCall(async (data: any, context) => {
|
||||
const callerId = context.auth?.uid
|
||||
if (!callerId) {
|
||||
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.')
|
||||
}
|
||||
|
||||
const coupleId = data?.coupleId
|
||||
if (typeof coupleId !== 'string' || coupleId.length === 0) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.')
|
||||
}
|
||||
|
||||
const dayKey = data?.dayKey
|
||||
if (!isValidDayKey(dayKey)) {
|
||||
throw new functions.https.HttpsError(
|
||||
'invalid-argument',
|
||||
`dayKey must be one of: ${DAY_KEYS.join(', ')}.`
|
||||
)
|
||||
}
|
||||
|
||||
const scores = data?.scores
|
||||
if (!isValidScoreMap(scores)) {
|
||||
throw new functions.https.HttpsError(
|
||||
'invalid-argument',
|
||||
`scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.`
|
||||
)
|
||||
}
|
||||
|
||||
const db = admin.firestore()
|
||||
const coupleRef = db.collection('couples').doc(coupleId)
|
||||
|
||||
// Caller must be a member of the couple.
|
||||
const coupleDoc = await coupleRef.get()
|
||||
if (!coupleDoc.exists) {
|
||||
throw new functions.https.HttpsError('not-found', 'Couple not found.')
|
||||
}
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
if (!userIds.includes(callerId)) {
|
||||
throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.')
|
||||
}
|
||||
|
||||
const now = admin.firestore.Timestamp.now()
|
||||
const outcomeRef = coupleRef.collection('outcomes').doc(dayKey)
|
||||
|
||||
const result = await db.runTransaction(async (tx) => {
|
||||
const existing = await tx.get(outcomeRef)
|
||||
const existingData = existing.exists ? (existing.data() ?? {}) : {}
|
||||
|
||||
// If this is a follow-up (non-baseline), a baseline must exist to compute delta.
|
||||
if (dayKey !== 'day_0') {
|
||||
const baselineRef = coupleRef.collection('outcomes').doc('day_0')
|
||||
const baselineSnap = await tx.get(baselineRef)
|
||||
if (!baselineSnap.exists) {
|
||||
throw new functions.https.HttpsError(
|
||||
'failed-precondition',
|
||||
'Baseline (day_0) outcome must be submitted before follow-up outcomes.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const answeredBy = (existingData.answeredBy ?? []) as string[]
|
||||
if (!answeredBy.includes(callerId)) {
|
||||
answeredBy.push(callerId)
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
dayKey,
|
||||
answeredBy,
|
||||
updatedAt: now,
|
||||
createdAt: (existingData.createdAt as admin.firestore.Timestamp | undefined) ?? now,
|
||||
}
|
||||
|
||||
if (dayKey === 'day_0') {
|
||||
payload.baseline = scores
|
||||
} else {
|
||||
payload.scores = scores
|
||||
}
|
||||
|
||||
// Delta is recalculated every time so repeated submissions stay consistent.
|
||||
if (dayKey !== 'day_0') {
|
||||
const baselineRef = coupleRef.collection('outcomes').doc('day_0')
|
||||
const baselineSnap = await tx.get(baselineRef)
|
||||
if (baselineSnap.exists) {
|
||||
const baseline = (baselineSnap.data()?.baseline ?? {}) as Record<string, number>
|
||||
const delta: Record<string, number> = {}
|
||||
for (const key of SCORE_KEYS) {
|
||||
delta[key] = (scores[key] ?? 0) - (baseline[key] ?? 0)
|
||||
}
|
||||
payload.delta = delta
|
||||
}
|
||||
}
|
||||
|
||||
tx.set(outcomeRef, payload, { merge: true })
|
||||
|
||||
// Per-user mirror for cross-relationship stats.
|
||||
const userOutcomeRef = db.collection('users').doc(callerId).collection('outcomes').doc(dayKey)
|
||||
tx.set(userOutcomeRef, {
|
||||
dayKey,
|
||||
coupleId,
|
||||
...(dayKey === 'day_0' ? { baseline: scores } : { scores, delta: payload.delta }),
|
||||
answeredBy: [callerId],
|
||||
createdAt: (existingData.createdAt as admin.firestore.Timestamp | undefined) ?? now,
|
||||
updatedAt: now,
|
||||
}, { merge: true })
|
||||
|
||||
return { dayKey, answeredBy }
|
||||
})
|
||||
|
||||
console.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`)
|
||||
|
||||
return { success: true, ...result }
|
||||
})
|
||||
|
|
@ -31,6 +31,8 @@ export { onCoupleLeave } from './couples/onCoupleLeave'
|
|||
export { leaveCoupleCallable } from './couples/leaveCoupleCallable'
|
||||
export { acceptInviteCallable } from './couples/acceptInviteCallable'
|
||||
export { createInviteCallable } from './couples/createInviteCallable'
|
||||
export { submitOutcomeCallable } from './couples/submitOutcomeCallable'
|
||||
export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
|
||||
export { onUserDelete } from './users/onUserDelete'
|
||||
export { onGameSessionUpdate } from './games/onGameSessionUpdate'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue