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:
parent
55699e17ed
commit
f0a05f779b
17
Future.md
17
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@
|
|||
</intent-filter>
|
||||
</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
|
||||
android:name=".core.notifications.AppMessagingService"
|
||||
android:exported="false">
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Closer</string>
|
||||
<string name="today_widget_description">Tonight\'s question + your streak, at a glance.</string>
|
||||
|
||||
<!-- ── Common actions ─────────────────────────────────────────── -->
|
||||
<string name="action_back">Back</string>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue