Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions android/app/src/main/java/com/noop/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,17 @@ object NoopPrefs {
of(context).edit().putBoolean(KEY_SKY_BEHIND_CARDS, enabled).apply()
}

/** "Reduce motion in NOOP" (opt-in, default OFF). The literal key matches Apple so the setting has
* one cross-platform identity. [rememberQuietMotion] observes it live for every looping surface. */
const val KEY_QUIET_MOTION = "noop.quietMotion"

fun quietMotion(context: Context): Boolean =
of(context).getBoolean(KEY_QUIET_MOTION, false)

fun setQuietMotion(context: Context, enabled: Boolean) {
of(context).edit().putBoolean(KEY_QUIET_MOTION, enabled).apply()
}

/** Coach on-device signals (v5): when ON, the opt-in BYO-key Coach's grounding context may include a
* SUMMARY-ONLY line of on-device correlations + Lab Book markers (no raw egress). A SECOND opt-in on
* top of the existing "let the coach use my data" consent. Default OFF, keeps the anonymity posture. */
Expand Down
42 changes: 40 additions & 2 deletions android/app/src/main/java/com/noop/ui/NoopMotion.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.os.PowerManager
import android.provider.Settings
import androidx.compose.animation.core.AnimationSpec
Expand Down Expand Up @@ -131,9 +132,45 @@ fun rememberPowerSaveMode(): Boolean {
return PowerSaveMonitor.isSaving.value
}

/**
* Process-wide live mirror of the in-app "Reduce motion in NOOP" preference. SharedPreferences keeps
* listeners weakly, so this object retains both the preferences and listener for the process lifetime.
* One observer feeds Compose snapshot state to every looping surface, avoiding a listener per composable.
*/
private object QuietMotionMonitor {
val enabled = mutableStateOf(false)
private var preferences: SharedPreferences? = null
private var listener: SharedPreferences.OnSharedPreferenceChangeListener? = null

/** Idempotent; called on the main thread from composition. */
fun ensureStarted(context: Context) {
if (preferences != null) return
val prefs = NoopPrefs.of(context.applicationContext)
enabled.value = prefs.getBoolean(NoopPrefs.KEY_QUIET_MOTION, false)
val observer = SharedPreferences.OnSharedPreferenceChangeListener { changed, key ->
if (key == NoopPrefs.KEY_QUIET_MOTION) {
enabled.value = changed.getBoolean(NoopPrefs.KEY_QUIET_MOTION, false)
}
}
preferences = prefs
listener = observer
prefs.registerOnSharedPreferenceChangeListener(observer)
}
}

/** The live in-app quiet-motion preference. Previews stay animated. */
@Composable
fun rememberQuietMotion(): Boolean {
if (LocalInspectionMode.current) return false
val context = LocalContext.current
remember(context) { QuietMotionMonitor.ensureStarted(context) }
return QuietMotionMonitor.enabled.value
}

/**
* True when a continuously-animating surface should render its single posed frame instead of running
* a frame loop — either the user has turned system animations off, or the system is in battery saver.
* a frame loop — the user turned system animations off, the system is in battery saver, or the in-app
* quiet-motion preference is enabled.
*
* **For frame loops only.** The one-shot helpers in this file (`CountUpText`, `staggeredAppear`, the
* 160 ms tweens in `LiquidPrimitives`) keep asking [rememberReduceMotion] on its own: those settle and
Expand All @@ -146,7 +183,8 @@ fun rememberPowerSaveMode(): Boolean {
* though the number is unmeasured here.
*/
@Composable
fun rememberPoseStill(): Boolean = rememberReduceMotion() || rememberPowerSaveMode()
fun rememberPoseStill(): Boolean =
rememberReduceMotion() || rememberPowerSaveMode() || rememberQuietMotion()

// MARK: - NoopMotion springs / tokens

Expand Down
16 changes: 16 additions & 0 deletions android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,9 @@ fun SettingsScreen(
// Display-only; SharedPreferences isn't reactive, so mirror into local state and persist on select.
var trendChartStyle by remember { mutableStateOf(UnitPrefs.trendChartStyle(context)) }
var sleepChartStyle by remember { mutableStateOf(UnitPrefs.sleepChartStyle(context)) }
// In-app quiet motion (#941), default OFF. The process-wide preference observer in NoopMotion makes
// this take effect on every currently composed looping surface as soon as the switch is flipped.
var quietMotion by remember { mutableStateOf(NoopPrefs.quietMotion(context)) }
// HRV window (#141) — whole-night vs deep-sleep (WHOOP-style). NOT display-only: it changes the computed
// avgHrv, so a switch clears the analyze watermark to force a re-score + re-baseline on the next pass.
var hrvWindow by remember { mutableStateOf(UnitPrefs.hrvWindow(context)) }
Expand Down Expand Up @@ -1104,6 +1107,19 @@ fun SettingsScreen(
adaptsToAvailableWidth = true,
)
}
// In-app "Reduce motion in NOOP" (#941) — parity with the Apple quiet-motion toggle. Poses every
// looping surface still via the third rememberPoseStill() signal. (SettingsRowDivider, not the
// file-private RowDivider used elsewhere, which isn't visible here.)
SettingsRowDivider()
SettingsToggleRow(
title = uiString(R.string.l10n_settings_screen_reduce_motion_in_noop_59a6180d),
detail = uiString(R.string.l10n_settings_screen_holds_the_liquid_gauges_the_sky_41872b57),
checked = quietMotion,
onCheckedChange = {
quietMotion = it
NoopPrefs.setQuietMotion(context, it)
},
)

// Day-cycle background (#698): the time-of-day scene behind Today. On by default. Off swaps it
// for a plain dark canvas for people who find the moving scene distracting. Takes effect next
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,8 @@
<string name="l10n_settings_screen_age_ff9f1ff3">Alter</string>
<string name="l10n_settings_screen_app_icon_abde7a74">App-Symbol</string>
<string name="l10n_settings_screen_appearance_41def7a0">Erscheinungsbild</string>
<string name="l10n_settings_screen_reduce_motion_in_noop_59a6180d">Bewegung in NOOP reduzieren</string>
<string name="l10n_settings_screen_holds_the_liquid_gauges_the_sky_41872b57">Hält die Flüssigkeitsanzeigen, den Himmel und die Neigungsreaktion still und schaltet den Bewegungssensor ab, der sie antreibt. Spart Akku. Der Stromsparmodus und die Systemeinstellung „Bewegung reduzieren“ tun dies bereits.</string>
<string name="l10n_settings_screen_auto_detect_workouts_bed4cf2a">Workouts automatisch erkennen</string>
<string name="l10n_settings_screen_automatic_backups_8a772f3c">Automatische Backups</string>
<string name="l10n_settings_screen_backup_restore_a1616284">Sicherung &amp; Wiederherstellung</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,8 @@
<string name="l10n_settings_screen_age_ff9f1ff3">Edad</string>
<string name="l10n_settings_screen_app_icon_abde7a74">Icono de la app</string>
<string name="l10n_settings_screen_appearance_41def7a0">Apariencia</string>
<string name="l10n_settings_screen_reduce_motion_in_noop_59a6180d">Reducir movimiento en NOOP</string>
<string name="l10n_settings_screen_holds_the_liquid_gauges_the_sky_41872b57">Mantiene quietos los medidores líquidos, el cielo y la respuesta a la inclinación, y apaga el sensor de movimiento que los impulsa. Ahorra batería. El modo de bajo consumo y el ajuste del sistema «Reducir movimiento» ya hacen esto.</string>
<string name="l10n_settings_screen_auto_detect_workouts_bed4cf2a">Detectar entrenamientos automáticamente</string>
<string name="l10n_settings_screen_automatic_backups_8a772f3c">Respaldos automáticos</string>
<string name="l10n_settings_screen_backup_restore_a1616284">Copia de seguridad y restauración</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,8 @@
<string name="l10n_settings_screen_age_ff9f1ff3">Âge</string>
<string name="l10n_settings_screen_app_icon_abde7a74">Icône de l\'app</string>
<string name="l10n_settings_screen_appearance_41def7a0">Apparence</string>
<string name="l10n_settings_screen_reduce_motion_in_noop_59a6180d">Réduire les animations dans NOOP</string>
<string name="l10n_settings_screen_holds_the_liquid_gauges_the_sky_41872b57">Fige les jauges liquides, le ciel et la réaction à l’inclinaison, et coupe le capteur de mouvement qui les anime. Économise la batterie. Le mode économie d’énergie et le réglage système « Réduire les animations » font déjà cela.</string>
<string name="l10n_settings_screen_auto_detect_workouts_bed4cf2a">Détecter automatiquement les entraînements</string>
<string name="l10n_settings_screen_automatic_backups_8a772f3c">Sauvegardes automatiques</string>
<string name="l10n_settings_screen_backup_restore_a1616284">Sauvegarde et restauration</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-pt-rPT/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,8 @@
<string name="l10n_settings_screen_age_ff9f1ff3">Idade</string>
<string name="l10n_settings_screen_app_icon_abde7a74">Ícone do aplicativo</string>
<string name="l10n_settings_screen_appearance_41def7a0">Aspeto</string>
<string name="l10n_settings_screen_reduce_motion_in_noop_59a6180d">Reduzir movimento no NOOP</string>
<string name="l10n_settings_screen_holds_the_liquid_gauges_the_sky_41872b57">Mantém parados os medidores líquidos, o céu e a resposta à inclinação, e desliga o sensor de movimento que os anima. Poupa bateria. O Modo de Baixo Consumo e a definição de sistema «Reduzir movimento» já fazem isto.</string>
<string name="l10n_settings_screen_auto_detect_workouts_bed4cf2a">Detecção automática de treinos</string>
<string name="l10n_settings_screen_automatic_backups_8a772f3c">Cópia de seguranças automáticos</string>
<string name="l10n_settings_screen_backup_restore_a1616284">Cópia de segurança e restauro</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,8 @@
<string name="l10n_settings_screen_age_ff9f1ff3">年龄</string>
<string name="l10n_settings_screen_app_icon_abde7a74">App 图标</string>
<string name="l10n_settings_screen_appearance_41def7a0">外观设置</string>
<string name="l10n_settings_screen_reduce_motion_in_noop_59a6180d">减少 NOOP 中的动态效果</string>
<string name="l10n_settings_screen_holds_the_liquid_gauges_the_sky_41872b57">让液态仪表、天空和倾斜响应保持静止,并关闭驱动它们的运动传感器。省电。低电量模式和系统的“减弱动态效果”设置已经会这样做。</string>
<string name="l10n_settings_screen_auto_detect_workouts_bed4cf2a">自动识别运动</string>
<string name="l10n_settings_screen_automatic_backups_8a772f3c">自动备份</string>
<string name="l10n_settings_screen_backup_restore_a1616284">备份与恢复</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1434,6 +1434,8 @@
<string name="l10n_settings_screen_age_ff9f1ff3">Age</string>
<string name="l10n_settings_screen_app_icon_abde7a74">App icon</string>
<string name="l10n_settings_screen_appearance_41def7a0">Appearance</string>
<string name="l10n_settings_screen_reduce_motion_in_noop_59a6180d">Reduce motion in NOOP</string>
<string name="l10n_settings_screen_holds_the_liquid_gauges_the_sky_41872b57">Holds the liquid gauges, the sky and the tilt response still, and turns off the motion sensor that drives them. Saves battery. Low Power Mode and the system Reduce Motion setting already do this.</string>
<string name="l10n_settings_screen_auto_detect_workouts_bed4cf2a">Auto-detect workouts</string>
<string name="l10n_settings_screen_automatic_backups_8a772f3c">Automatic backups</string>
<string name="l10n_settings_screen_backup_restore_a1616284">Backup &amp; restore</string>
Expand Down
21 changes: 14 additions & 7 deletions android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,17 @@ class PoseStillCoverageTest {
)
}

/**
* The gate is reduce-motion OR battery saver. Losing either half is silent — the screen looks right
* in whichever mode still works — so both reads are pinned here rather than left to the composable.
*/
/** The gate combines all three live signals. Losing one is silent, so pin their exact census here. */
@Test
fun poseStillGateCombinesReduceMotionAndBatterySaver() {
fun poseStillGateCombinesAllThreeLiveSignals() {
val motion = File(uiDir(), "NoopMotion.kt")
assertTrue("NoopMotion.kt missing", motion.isFile)
val code = stripComments(motion.readText()).replace(Regex("\\s+"), " ")
assertTrue(
"rememberPoseStill must be the OR of both signals: $code",
code.contains("fun rememberPoseStill(): Boolean = rememberReduceMotion() || rememberPowerSaveMode()"),
"rememberPoseStill must OR system motion, battery saver, and the in-app preference: $code",
code.contains(
"fun rememberPoseStill(): Boolean = rememberReduceMotion() || rememberPowerSaveMode() || rememberQuietMotion()",
),
)
assertTrue(
"battery saver must be read from PowerManager.isPowerSaveMode",
Expand All @@ -119,5 +118,13 @@ class PoseStillCoverageTest {
"and kept live — a read-once value would strand the screen animating after the user flips it",
code.contains("ACTION_POWER_SAVE_MODE_CHANGED"),
)
assertTrue(
"the in-app preference must use the cross-platform key",
code.contains("NoopPrefs.KEY_QUIET_MOTION"),
)
assertTrue(
"and stay live without leaving the screen",
code.contains("registerOnSharedPreferenceChangeListener"),
)
}
}