diff --git a/Future.md b/Future.md
index 2b22a871..2090d29a 100644
--- a/Future.md
+++ b/Future.md
@@ -35,6 +35,23 @@ Non-blocking ideas: things that work today but could be better, plus feature ide
messages; today mitigated by the `generation` counter + a Phase-1 Firestore cross-check. Add a signed/monotonic
freshness signal for the Option-B world.
+## Today widget (Batch 3.2 follow-ons)
+
+The Glance "Today" widget shipped (R29): content-free daily-question state + streak, app-lock →
+generic mode, taps into the daily question via `MainActivity`'s `app_route` extra. Verified: provider
+registers, the `HomeViewModel` → `TodayWidgetUpdater` → `today_widget` DataStore bridge writes, headline
+logic unit-tested, no crash. Remaining:
+
+- **On-home-screen render check.** Binding a widget to the launcher isn't adb-scriptable; confirm the
+ Glance composition renders (light/dark) on a device, or add an instrumented test that binds via
+ `AppWidgetManager`.
+- **Remote Config kill switch (`widget_enabled`).** Deferred: the app has **no** RemoteConfig wrapper yet
+ despite `firebase-config-ktx` being a dep. Build one shared wrapper once (fetch/activate + defaults),
+ then apply the kill switches the plan wants across the widget, solo mode, and deep links. Until then the
+ widget degrades safely on its own (generic card + taps into the app).
+- **Day-rollover refresh.** Covered today by `updatePeriodMillis=30m` + app-open updates; a WorkManager
+ day-boundary trigger (plan's suggestion) would make the "Tonight's question is ready" flip crisper.
+
## Solo / pre-pairing (Batch 2.2 follow-ons)
The core of the pre-pairing experience shipped (R29): unpaired users can answer the daily question solo
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e2c5586a..80ef6da1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -195,6 +195,10 @@ dependencies {
// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.2")
+ // Glance — home-screen "Today" widget (content-free: daily-question state + streak only)
+ implementation("androidx.glance:glance-appwidget:1.1.1")
+ implementation("androidx.glance:glance-material3:1.1.1")
+
// Encrypted storage
implementation("androidx.security:security-crypto:1.0.0")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8a9e2bcb..c316560d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -52,6 +52,17 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt
index 7795567d..e73d3554 100644
--- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt
@@ -43,6 +43,8 @@ import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
@@ -234,7 +236,8 @@ class HomeViewModel @Inject constructor(
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource,
private val answerDataSource: FirestoreAnswerDataSource,
private val backupManager: app.closer.data.backup.BackupManager,
- private val retentionAnalytics: app.closer.analytics.RetentionAnalytics
+ private val retentionAnalytics: app.closer.analytics.RetentionAnalytics,
+ private val widgetUpdater: app.closer.widget.TodayWidgetUpdater
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@@ -252,6 +255,29 @@ class HomeViewModel @Inject constructor(
observeAnswers()
observeCoupleState()
observeUnreadActivity()
+ observeForWidget()
+ }
+
+ /**
+ * Keeps the home-screen "Today" widget in step with the daily-question state + streak. Combines
+ * the UI state with the app-lock setting so the widget can drop to generic mode when the lock is
+ * on. Best-effort + content-free (see TodayWidgetUpdater).
+ */
+ private fun observeForWidget() {
+ viewModelScope.launch {
+ combine(
+ _uiState,
+ settingsRepository.settings
+ ) { ui, settings ->
+ app.closer.widget.TodayWidgetSnapshot(
+ signedIn = authRepository.currentUserId != null,
+ paired = ui.isPaired,
+ appLockEnabled = settings.biometricLoginEnabled,
+ dailyState = ui.dailyQuestionState.name,
+ streak = ui.streakCount
+ )
+ }.distinctUntilChanged().collect { widgetUpdater.update(it) }
+ }
}
private fun observeUnreadActivity() {
diff --git a/app/src/main/java/app/closer/widget/TodayWidget.kt b/app/src/main/java/app/closer/widget/TodayWidget.kt
new file mode 100644
index 00000000..a6c69015
--- /dev/null
+++ b/app/src/main/java/app/closer/widget/TodayWidget.kt
@@ -0,0 +1,109 @@
+package app.closer.widget
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import app.closer.MainActivity
+import app.closer.core.navigation.AppRoute
+
+/**
+ * A glanceable home-screen widget: today's daily-question state + streak, nothing more.
+ * Content-free by design — no question or answer text ever renders here. When the biometric
+ * app-lock is on, it drops to a generic card so partner-activity state doesn't sit on the home
+ * screen the user chose to protect. Tapping opens the relevant screen.
+ */
+class TodayWidget : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val snapshot = TodayWidgetState.read(context)
+ provideContent {
+ GlanceTheme {
+ TodayWidgetContent(context, snapshot)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TodayWidgetContent(context: Context, snapshot: TodayWidgetSnapshot) {
+ val launch = Intent(context, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ // Route straight to the daily question only when it's safe + meaningful; otherwise a plain
+ // launch. app_route is read by MainActivity.deepLinkRouteFromIntent.
+ if (snapshot.signedIn && snapshot.paired && !snapshot.appLockEnabled) {
+ putExtra("app_route", AppRoute.DAILY_QUESTION)
+ }
+ }
+
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(GlanceTheme.colors.primaryContainer)
+ .cornerRadius(20.dp)
+ .padding(16.dp)
+ .clickable(actionStartActivity(launch)),
+ verticalAlignment = Alignment.Vertical.CenterVertically,
+ horizontalAlignment = Alignment.Horizontal.Start,
+ ) {
+ Text(
+ text = "Closer",
+ style = TextStyle(
+ color = GlanceTheme.colors.onPrimaryContainer,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ )
+ Text(
+ text = headlineFor(snapshot),
+ style = TextStyle(
+ color = GlanceTheme.colors.onPrimaryContainer,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ maxLines = 3,
+ )
+ if (snapshot.paired && snapshot.streak > 0 && !snapshot.appLockEnabled) {
+ Text(
+ text = "🔥 ${snapshot.streak} day streak",
+ style = TextStyle(color = GlanceTheme.colors.onPrimaryContainer, fontSize = 13.sp)
+ )
+ }
+ }
+}
+
+/** Content-free headline derived only from state flags. */
+internal fun headlineFor(s: TodayWidgetSnapshot): String = when {
+ !s.signedIn -> "Open Closer"
+ !s.paired -> "Invite your partner to begin"
+ s.appLockEnabled -> "Something's waiting in Closer"
+ else -> when (s.dailyState) {
+ "USER_ANSWERED_PARTNER_PENDING" -> "Waiting for your partner"
+ "PARTNER_ANSWERED_USER_PENDING" -> "Your turn — answer to reveal"
+ "BOTH_ANSWERED" -> "Your reveal is waiting"
+ "REVEALED" -> "You opened tonight's question"
+ else -> "Tonight's question is ready"
+ }
+}
+
+class TodayWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = TodayWidget()
+}
diff --git a/app/src/main/java/app/closer/widget/TodayWidgetState.kt b/app/src/main/java/app/closer/widget/TodayWidgetState.kt
new file mode 100644
index 00000000..5baac9dd
--- /dev/null
+++ b/app/src/main/java/app/closer/widget/TodayWidgetState.kt
@@ -0,0 +1,59 @@
+package app.closer.widget
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+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.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+/**
+ * Content-free snapshot the "Today" widget renders. Holds ONLY state flags + the streak count —
+ * never question or answer text. When the app-lock is on, the widget shows a generic card, so
+ * even the (metadata) daily-question state stays off the lock screen the user chose to protect.
+ */
+data class TodayWidgetSnapshot(
+ val signedIn: Boolean = false,
+ val paired: Boolean = false,
+ val appLockEnabled: Boolean = false,
+ val dailyState: String = "UNANSWERED",
+ val streak: Int = 0,
+)
+
+/**
+ * Dedicated DataStore for the widget (separate file from app settings so the widget process and the
+ * app never contend on the same preferences file).
+ */
+private val Context.todayWidgetDataStore: DataStore by preferencesDataStore("today_widget")
+
+object TodayWidgetState {
+ private val SIGNED_IN = booleanPreferencesKey("signed_in")
+ private val PAIRED = booleanPreferencesKey("paired")
+ private val APP_LOCK = booleanPreferencesKey("app_lock")
+ private val DAILY_STATE = stringPreferencesKey("daily_state")
+ private val STREAK = intPreferencesKey("streak")
+
+ suspend fun write(context: Context, snapshot: TodayWidgetSnapshot) {
+ context.todayWidgetDataStore.edit {
+ it[SIGNED_IN] = snapshot.signedIn
+ it[PAIRED] = snapshot.paired
+ it[APP_LOCK] = snapshot.appLockEnabled
+ it[DAILY_STATE] = snapshot.dailyState
+ it[STREAK] = snapshot.streak
+ }
+ }
+
+ suspend fun read(context: Context): TodayWidgetSnapshot {
+ val prefs = context.todayWidgetDataStore.data.first()
+ return TodayWidgetSnapshot(
+ signedIn = prefs[SIGNED_IN] ?: false,
+ paired = prefs[PAIRED] ?: false,
+ appLockEnabled = prefs[APP_LOCK] ?: false,
+ dailyState = prefs[DAILY_STATE] ?: "UNANSWERED",
+ streak = prefs[STREAK] ?: 0,
+ )
+ }
+}
diff --git a/app/src/main/java/app/closer/widget/TodayWidgetUpdater.kt b/app/src/main/java/app/closer/widget/TodayWidgetUpdater.kt
new file mode 100644
index 00000000..92dfaf74
--- /dev/null
+++ b/app/src/main/java/app/closer/widget/TodayWidgetUpdater.kt
@@ -0,0 +1,24 @@
+package app.closer.widget
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * App-side entry point for keeping the "Today" widget in step with the home state. Injected into
+ * the Home ViewModel, which calls [update] whenever it recomputes the daily-question state/streak.
+ * Best-effort — a widget refresh must never affect app behavior.
+ */
+@Singleton
+class TodayWidgetUpdater @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ suspend fun update(snapshot: TodayWidgetSnapshot) {
+ runCatching {
+ TodayWidgetState.write(context, snapshot)
+ TodayWidget().updateAll(context)
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5286b036..1120eb0a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,6 +1,7 @@
Closer
+ Tonight\'s question + your streak, at a glance.
Back
diff --git a/app/src/main/res/xml/today_widget_info.xml b/app/src/main/res/xml/today_widget_info.xml
new file mode 100644
index 00000000..7b115a3a
--- /dev/null
+++ b/app/src/main/res/xml/today_widget_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/app/src/test/java/app/closer/widget/TodayWidgetTest.kt b/app/src/test/java/app/closer/widget/TodayWidgetTest.kt
new file mode 100644
index 00000000..97a18421
--- /dev/null
+++ b/app/src/test/java/app/closer/widget/TodayWidgetTest.kt
@@ -0,0 +1,52 @@
+package app.closer.widget
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+class TodayWidgetTest {
+
+ private fun snap(
+ signedIn: Boolean = true,
+ paired: Boolean = true,
+ appLock: Boolean = false,
+ state: String = "UNANSWERED",
+ ) = TodayWidgetSnapshot(signedIn, paired, appLock, state, streak = 0)
+
+ @Test
+ fun `signed-out shows a neutral open prompt`() {
+ assertEquals("Open Closer", headlineFor(snap(signedIn = false)))
+ }
+
+ @Test
+ fun `unpaired invites the partner`() {
+ assertEquals("Invite your partner to begin", headlineFor(snap(paired = false)))
+ }
+
+ @Test
+ fun `app-lock hides the daily state behind a generic headline`() {
+ val locked = headlineFor(snap(appLock = true, state = "BOTH_ANSWERED"))
+ assertEquals("Something's waiting in Closer", locked)
+ // The specific state must NOT leak when the lock is on.
+ assertFalse(locked.contains("reveal", ignoreCase = true))
+ }
+
+ @Test
+ fun `each daily state maps to its headline when unlocked`() {
+ assertEquals("Tonight's question is ready", headlineFor(snap(state = "UNANSWERED")))
+ assertEquals("Waiting for your partner", headlineFor(snap(state = "USER_ANSWERED_PARTNER_PENDING")))
+ assertEquals("Your turn — answer to reveal", headlineFor(snap(state = "PARTNER_ANSWERED_USER_PENDING")))
+ assertEquals("Your reveal is waiting", headlineFor(snap(state = "BOTH_ANSWERED")))
+ assertEquals("You opened tonight's question", headlineFor(snap(state = "REVEALED")))
+ }
+
+ @Test
+ fun `never renders question or answer text - only state-derived copy`() {
+ // Exhaustive: no snapshot field carries content, so no headline can contain any.
+ val allStates = listOf("UNANSWERED", "USER_ANSWERED_PARTNER_PENDING", "PARTNER_ANSWERED_USER_PENDING", "BOTH_ANSWERED", "REVEALED")
+ allStates.forEach { s ->
+ val h = headlineFor(snap(state = s))
+ assertFalse(h.isBlank())
+ }
+ }
+}