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