feat: add gentle reminder callable and wire into HomeViewModel (batch v1.0.12)

- GENTLE_REMINDER notification type with warm copy
- sendGentleReminderCallable Cloud Function
- HomeViewModel.sendGentleReminder() calls function, shows snackbar
- Snackbar event consumed after display
This commit is contained in:
null 2026-06-19 23:47:01 -05:00
parent 7dc14af627
commit 0e75b3b536
9 changed files with 318 additions and 4 deletions

View File

@ -174,6 +174,12 @@ enum class PartnerNotificationType(
body = "Something you sealed together is ready to open.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
),
GENTLE_REMINDER(
title = "Your partner is thinking about you.",
body = "They left tonight's question open. Answer when you're ready.",
channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS,
rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER
);
/**
@ -186,6 +192,7 @@ enum class PartnerNotificationType(
PARTNER_COMPLETED_PART -> AppRoute.PLAY
CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
GENTLE_REMINDER -> AppRoute.DAILY_QUESTION
}
companion object {
@ -200,6 +207,7 @@ enum class PartnerNotificationType(
"partner_completed_part" -> PARTNER_COMPLETED_PART
"challenge_waiting" -> CHALLENGE_WAITING
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
"gentle_reminder" -> GENTLE_REMINDER
else -> null
}
}

View File

@ -77,6 +77,13 @@ fun HomeScreen(
}
}
LaunchedEffect(state.reminderSentEvent) {
if (state.reminderSentEvent) {
snackbarHostState.showSnackbar("Reminder sent to your partner.")
viewModel.consumeReminderSentEvent()
}
}
LaunchedEffect(state.needsRecovery) {
if (state.needsRecovery) {
onNavigate(app.closer.core.navigation.AppRoute.RECOVERY)

View File

@ -20,6 +20,7 @@ import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.repository.UserRepository
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions
import dagger.hilt.android.lifecycle.HiltViewModel
import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput
import app.closer.ui.home.HomePriorityEngine.Priority
@ -28,6 +29,7 @@ import java.time.LocalDate
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -122,7 +124,8 @@ data class HomeUiState(
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
val weeklyRecapReady: Boolean = false
val weeklyRecapReady: Boolean = false,
val reminderSentEvent: Boolean = false
)
@HiltViewModel
@ -134,6 +137,7 @@ class HomeViewModel @Inject constructor(
private val userRepository: UserRepository,
private val encryptionManager: CoupleEncryptionManager,
private val db: FirebaseFirestore,
private val functions: FirebaseFunctions,
private val questionSessionRepository: QuestionSessionRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource,
@ -309,9 +313,22 @@ class HomeViewModel @Inject constructor(
* Notification wiring is intentionally a no-op until Batch 6.
*/
fun sendGentleReminder() {
// TODO(Batch 6): wire partner-triggered notification via FCM.
// Only meaningful when we have answered but partner hasn't.
val state = _uiState.value
if (state.dailyQuestionState != DailyQuestionState.USER_ANSWERED_PARTNER_PENDING) return
viewModelScope.launch {
runCatching {
functions.getHttpsCallable("sendGentleReminderCallable").call().await()
}.onSuccess {
_uiState.update { it.copy(reminderSentEvent = true) }
}.onFailure { e ->
Log.w(TAG, "Gentle reminder failed", e)
}
}
}
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
private fun observeAnswers() {
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->

View File

@ -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.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = 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.
@ -49,6 +49,8 @@ Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: funct
var reminders_1 = require("./notifications/reminders");
Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } });
Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } });
var sendGentleReminderCallable_1 = require("./notifications/sendGentleReminderCallable");
Object.defineProperty(exports, "sendGentleReminderCallable", { enumerable: true, get: function () { return sendGentleReminderCallable_1.sendGentleReminderCallable; } });
var gameRetention_1 = require("./notifications/gameRetention");
Object.defineProperty(exports, "sendChallengeDayReminders", { enumerable: true, get: function () { return gameRetention_1.sendChallengeDayReminders; } });
Object.defineProperty(exports, "unlockDueMemoryCapsules", { enumerable: true, get: function () { return gameRetention_1.unlockDueMemoryCapsules; } });

View File

@ -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,+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,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,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"}

View File

@ -0,0 +1,144 @@
"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.sendGentleReminderCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
* 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.
*
* The notification is both an FCM push (for the system tray) and an entry in
* the partner's notification_queue (for in-app display).
*/
exports.sendGentleReminderCallable = functions.https.onCall(async (_data, context) => {
var _a, _b, _c, _d, _e;
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();
// ── 1. Resolve couple + partner ──────────────────────────────────────────
const userDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.');
}
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.');
}
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const partnerId = userIds.find((id) => id !== callerId);
if (!partnerId) {
throw new functions.https.HttpsError('failed-precondition', 'No partner found.');
}
// ── 2. 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')
.doc(coupleId)
.collection('gentle_reminders')
.doc(today);
const existingLock = await lockRef.get();
if (existingLock.exists) {
return { sent: false, reason: 'already_sent_today' };
}
// ── 3. Collect partner FCM tokens ────────────────────────────────────────
const tokens = [];
const partnerDoc = await db.collection('users').doc(partnerId).get();
if (partnerDoc.exists) {
const legacyToken = (_e = partnerDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken);
}
}
const tokenSnap = await db
.collection('users')
.doc(partnerId)
.collection('fcmTokens')
.get();
tokenSnap.docs.forEach((doc) => {
var _a;
const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t);
}
});
// ── 4. Write in-app notification record ──────────────────────────────────
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
type: 'gentle_reminder',
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
read: false,
sent: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
// ── 5. Claim the daily rate-limit lock ───────────────────────────────────
await lockRef.set({
sentBy: callerId,
sentAt: admin.firestore.FieldValue.serverTimestamp(),
});
// ── 6. Send FCM push ─────────────────────────────────────────────────────
if (tokens.length > 0) {
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send({
token,
notification: {
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
},
data: {
type: 'gentle_reminder',
couple_id: coupleId,
},
})));
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[sendGentleReminderCallable] FCM failed for token ${tokens[i]}:`, result.reason);
}
});
}
console.log(`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`);
return { sent: true };
});
//# sourceMappingURL=sendGentleReminderCallable.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"sendGentleReminderCallable.js","sourceRoot":"","sources":["../../src/notifications/sendGentleReminderCallable.ts"],"names":[],"mappings":"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"}

View File

@ -14,6 +14,7 @@ export {
sendDailyQuestionReminder,
sendPartnerAnsweredNotification,
} from './notifications/reminders'
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'
export {
sendChallengeDayReminders,
unlockDueMemoryCapsules,

View File

@ -0,0 +1,134 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
/**
* 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.
*
* The notification is both an FCM push (for the system tray) and an entry in
* the partner's notification_queue (for in-app display).
*/
export const sendGentleReminderCallable = functions.https.onCall(async (_data, context) => {
const callerId = context.auth?.uid
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.')
}
const db = admin.firestore()
// ── 1. Resolve couple + partner ──────────────────────────────────────────
const userDoc = await db.collection('users').doc(callerId).get()
const coupleId = userDoc.data()?.coupleId as string | undefined
if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.')
}
const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.')
}
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== callerId)
if (!partnerId) {
throw new functions.https.HttpsError('failed-precondition', 'No partner found.')
}
// ── 2. 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')
.doc(coupleId)
.collection('gentle_reminders')
.doc(today)
const existingLock = await lockRef.get()
if (existingLock.exists) {
return { sent: false, reason: 'already_sent_today' }
}
// ── 3. Collect partner FCM tokens ────────────────────────────────────────
const tokens: string[] = []
const partnerDoc = await db.collection('users').doc(partnerId).get()
if (partnerDoc.exists) {
const legacyToken = partnerDoc.data()?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
}
const tokenSnap = await db
.collection('users')
.doc(partnerId)
.collection('fcmTokens')
.get()
tokenSnap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t)
}
})
// ── 4. Write in-app notification record ──────────────────────────────────
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
type: 'gentle_reminder',
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
read: false,
sent: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
// ── 5. Claim the daily rate-limit lock ───────────────────────────────────
await lockRef.set({
sentBy: callerId,
sentAt: admin.firestore.FieldValue.serverTimestamp(),
})
// ── 6. Send FCM push ─────────────────────────────────────────────────────
if (tokens.length > 0) {
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
token,
notification: {
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
},
data: {
type: 'gentle_reminder',
couple_id: coupleId,
},
})
)
)
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(
`[sendGentleReminderCallable] FCM failed for token ${tokens[i]}:`,
result.reason
)
}
})
}
console.log(
`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`
)
return { sent: true }
})