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 cc7be49c01..679816ef6c 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -687,6 +687,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 469a2f735b..284580a31a 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -585,6 +585,9 @@ fun SettingsScreen( // Trend charts (Line / Bar) — flips the Trends tab between the gradient line and value-ramp bars. // Display-only; SharedPreferences isn't reactive, so mirror into local state and persist on select. var trendChartStyle by remember { mutableStateOf(UnitPrefs.trendChartStyle(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)) } @@ -1084,6 +1087,17 @@ fun SettingsScreen( }, ) } + RowDivider() + ToggleRow( + 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) + }, + ) + RowDivider() // 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 d449d2b784..7c78f757ab 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1421,6 +1421,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 89183ed33a..66adc2d369 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1251,6 +1251,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 97c17a2b0d..774c2381ef 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1251,6 +1251,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 02f08377eb..48836261d7 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1402,6 +1402,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 9e96f3f21f..90c78a94cd 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1383,6 +1383,8 @@ 年龄 App 图标 外观设置 + 减少 NOOP 中的动态效果 + 让液态仪表、天空和倾斜响应保持静止,并关闭驱动它们的运动传感器。省电。低电量模式和系统的“减弱动态效果”设置已经会这样做。 自动识别运动 自动备份 备份与恢复 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 761a686355..69093a19c0 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1430,6 +1430,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"), + ) } }