feat(widget): Glance "Today" home-screen widget

Content-free daily-question state + streak, mirrored from HomeViewModel via a
dedicated DataStore (TodayWidgetUpdater -> TodayWidgetState). App-lock drops it
to a generic card so partner-activity state doesn't sit on the protected home
screen; signed-out/unpaired show neutral prompts. Tapping opens the daily
question via MainActivity's app_route extra.

Verified: provider registers, state bridge writes, headline logic unit-tested
(TodayWidgetTest), no crash on launch. On-home-screen render + a Remote Config
kill switch are tracked in Future.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 20:40:28 -05:00
parent 55699e17ed
commit f0a05f779b
10 changed files with 316 additions and 1 deletions

View File

@ -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 messages; today mitigated by the `generation` counter + a Phase-1 Firestore cross-check. Add a signed/monotonic
freshness signal for the Option-B world. 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) ## 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 The core of the pre-pairing experience shipped (R29): unpaired users can answer the daily question solo

View File

@ -195,6 +195,10 @@ dependencies {
// DataStore // DataStore
implementation("androidx.datastore:datastore-preferences:1.1.2") 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 // Encrypted storage
implementation("androidx.security:security-crypto:1.0.0") implementation("androidx.security:security-crypto:1.0.0")

View File

@ -52,6 +52,17 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".widget.TodayWidgetReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/today_widget_info" />
</receiver>
<service <service
android:name=".core.notifications.AppMessagingService" android:name=".core.notifications.AppMessagingService"
android:exported="false"> android:exported="false">

View File

@ -43,6 +43,8 @@ import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -234,7 +236,8 @@ class HomeViewModel @Inject constructor(
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource, private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource,
private val answerDataSource: FirestoreAnswerDataSource, private val answerDataSource: FirestoreAnswerDataSource,
private val backupManager: app.closer.data.backup.BackupManager, 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() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
@ -252,6 +255,29 @@ class HomeViewModel @Inject constructor(
observeAnswers() observeAnswers()
observeCoupleState() observeCoupleState()
observeUnreadActivity() 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() { private fun observeUnreadActivity() {

View File

@ -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()
}

View File

@ -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<Preferences> 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,
)
}
}

View File

@ -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)
}
}
}

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Closer</string> <string name="app_name">Closer</string>
<string name="today_widget_description">Tonight\'s question + your streak, at a glance.</string>
<!-- ── Common actions ─────────────────────────────────────────── --> <!-- ── Common actions ─────────────────────────────────────────── -->
<string name="action_back">Back</string> <string name="action_back">Back</string>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="180dp"
android:minHeight="80dp"
android:targetCellWidth="3"
android:targetCellHeight="1"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/today_widget_description"
android:previewLayout="@layout/glance_default_loading_layout"
android:initialLayout="@layout/glance_default_loading_layout"
android:updatePeriodMillis="1800000" />

View File

@ -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())
}
}
}