diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt
index ba895a44e7..444b649605 100644
--- a/android/app/src/main/java/com/noop/ui/MainActivity.kt
+++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt
@@ -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. */
diff --git a/android/app/src/main/java/com/noop/ui/NoopMotion.kt b/android/app/src/main/java/com/noop/ui/NoopMotion.kt
index bf255db439..7ff18224ca 100644
--- a/android/app/src/main/java/com/noop/ui/NoopMotion.kt
+++ b/android/app/src/main/java/com/noop/ui/NoopMotion.kt
@@ -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
@@ -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
@@ -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
diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
index cae0f2e5fb..25c51a509a 100644
--- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
@@ -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)) }
@@ -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
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index 9616b456d6..f09591c9e0 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -1425,6 +1425,8 @@
Alter
App-Symbol
Erscheinungsbild
+ Bewegung in NOOP reduzieren
+ 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.
Workouts automatisch erkennen
Automatische Backups
Sicherung & Wiederherstellung
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 1a71572ae0..a7804fbb02 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -1252,6 +1252,8 @@
Edad
Icono de la app
Apariencia
+ Reducir movimiento en NOOP
+ 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.
Detectar entrenamientos automáticamente
Respaldos automáticos
Copia de seguridad y restauración
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index 2f01e00c57..1e42b50226 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -1252,6 +1252,8 @@
Âge
Icône de l\'app
Apparence
+ Réduire les animations dans NOOP
+ 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.
Détecter automatiquement les entraînements
Sauvegardes automatiques
Sauvegarde et restauration
diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml
index af30de365c..6d34d904d0 100644
--- a/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -1406,6 +1406,8 @@
Idade
Ícone do aplicativo
Aspeto
+ Reduzir movimento no NOOP
+ 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.
Detecção automática de treinos
Cópia de seguranças automáticos
Cópia de segurança e restauro
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index 589d597b56..294d3c5175 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -1387,6 +1387,8 @@
年龄
App 图标
外观设置
+ 减少 NOOP 中的动态效果
+ 让液态仪表、天空和倾斜响应保持静止,并关闭驱动它们的运动传感器。省电。低电量模式和系统的“减弱动态效果”设置已经会这样做。
自动识别运动
自动备份
备份与恢复
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index c0f930fb02..487559ddc7 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1434,6 +1434,8 @@
Age
App icon
Appearance
+ Reduce motion in NOOP
+ 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.
Auto-detect workouts
Automatic backups
Backup & restore
diff --git a/android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt b/android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt
index 10aabc4861..fc7fe638e4 100644
--- a/android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt
+++ b/android/app/src/test/java/com/noop/ui/PoseStillCoverageTest.kt
@@ -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",
@@ -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"),
+ )
}
}