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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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"?>
|
<?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>
|
||||||
|
|
|
||||||
|
|
@ -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