diff --git a/src/core/entry-point.tsx b/src/core/entry-point.tsx index ea53ff6..2be8097 100644 --- a/src/core/entry-point.tsx +++ b/src/core/entry-point.tsx @@ -1,6 +1,6 @@ import * as Font from "expo-font"; import React, { FC, useEffect } from "react"; -import { Platform, UIManager, View, LayoutAnimation } from "react-native"; +import { Appearance, Platform, UIManager, View, LayoutAnimation } from "react-native"; import { fonts as fontAssets } from "@breathly/assets/fonts"; import { Navigator } from "@breathly/core/navigator"; import { useHydration, useSettingsStore } from "@breathly/stores/settings"; @@ -40,6 +40,17 @@ const Main: FC = () => { useStickyImmersiveReset(); useThemedStatusBar(); + // Native views take their colours from the system appearance, not from the app's own + // theme: the iOS large title and the picker wheel are UIKit, and they stayed in light mode + // when the user turned "Use system theme" off and chose Dark — a dark title on a dark + // background. Overriding the app's appearance is what makes every native view follow the + // chosen theme, rather than patching each one by hand. "unspecified" hands control back to + // the system. It changes the app's appearance only, never the system's. + useEffect(() => { + if (!hydrated) return; + Appearance.setColorScheme(shouldFollowSystemDarkMode ? "unspecified" : theme); + }, [hydrated, shouldFollowSystemDarkMode, theme]); + // Animate the layout when the stored theme arrives, and on every later change. // The color scheme itself now comes from the settings store, through // `useColorScheme`, so there is nothing to push into a styling library. diff --git a/src/screens/exercise-screen/accessibility-announcements.ts b/src/screens/exercise-screen/accessibility-announcements.ts index 75aab38..e79a0fa 100644 --- a/src/screens/exercise-screen/accessibility-announcements.ts +++ b/src/screens/exercise-screen/accessibility-announcements.ts @@ -30,3 +30,5 @@ export const getInterludeAccessibilityLabel = (secondsLeft: number) => `Starting session in ${secondsLeft}`; export const sessionCompleteAnnouncement = "Session complete"; + +export const sessionPausedAnnouncement = "Session paused"; diff --git a/src/screens/exercise-screen/exercise-screen.tsx b/src/screens/exercise-screen/exercise-screen.tsx index 1e724b9..87587de 100644 --- a/src/screens/exercise-screen/exercise-screen.tsx +++ b/src/screens/exercise-screen/exercise-screen.tsx @@ -11,8 +11,10 @@ import { widestDeviceDimension } from "@breathly/design/metrics"; import { useColorScheme, useThemeColors } from "@breathly/design/theme"; import { fontFamilies, fontSizes } from "@breathly/design/typography"; import { + announceForScreenReader, announceLiveRegionUpdate, getStepAccessibilityLabel, + sessionPausedAnnouncement, } from "@breathly/screens/exercise-screen/accessibility-announcements"; import { AnimatedDots } from "@breathly/screens/exercise-screen/animated-dots"; import { @@ -65,11 +67,6 @@ export const ExerciseScreen: FC subscription.remove(); @@ -151,6 +155,11 @@ export const ExerciseScreen: FC )} {session.status === "completed" && } + {/* The countdown and the paused screen need the display awake as much as the exercise + does — a screen that locks during the countdown pauses the session before it starts. + The completion screen does not: it never dismisses itself, so holding the display on + there would keep it lit until the user came back to the phone. */} + {session.status !== "completed" && } { + useKeepAwake(); + return null; +}; + interface ExerciseRunningFragmentProps { onComplete: () => unknown; onStepChange: (stepMetadata: StepMetadata) => unknown; @@ -288,9 +303,21 @@ interface ExercisePausedProps { const ExercisePaused: FC = ({ resumeStatus, onResume }) => { const isDarkMode = useColorScheme() === "dark"; const theme = useThemeColors(); + + // The step announcements simply stop when the session pauses. Without this a screen-reader + // user is told nothing at all, and the completion screen already announces itself. + useEffect(() => { + announceForScreenReader(sessionPausedAnnouncement); + }, []); + return ( - Paused + + Paused + {resumeStatus === "interlude" ? "The starting countdown was interrupted." diff --git a/src/screens/exercise-screen/step-animation.ts b/src/screens/exercise-screen/step-animation.ts index 5d5cacc..e8e33c1 100644 --- a/src/screens/exercise-screen/step-animation.ts +++ b/src/screens/exercise-screen/step-animation.ts @@ -25,22 +25,21 @@ export const createStepAnimation = ({ durationMs, }: StepAnimationOptions): Animated.CompositeAnimation => { const textAnimDurationMs = getTextAnimDurationMs(durationMs); + // `stopTogether` stays at its default. The ratio above already keeps the two fades inside + // the step, so nothing interrupts the circle and the flag has no work to do — but it would + // make an interrupted circle report `finished: true`, and `loopAnimations` would then step + // on forever with a frozen circle instead of stopping. A loud failure is the right one here. return Animated.stagger(Math.max(0, durationMs - textAnimDurationMs), [ - Animated.parallel( - [ - animate(exerciseAnimVal, { - toValue: toValue, - duration: durationMs, - }), - animate(textAnimVal, { - toValue: 1, - duration: textAnimDurationMs, - }), - ], - // The breathing circle carries the rhythm of the exercise: it must - // continue even if the label animation stops. - { stopTogether: false }, - ), + Animated.parallel([ + animate(exerciseAnimVal, { + toValue: toValue, + duration: durationMs, + }), + animate(textAnimVal, { + toValue: 1, + duration: textAnimDurationMs, + }), + ]), animate(textAnimVal, { toValue: 0, duration: textAnimDurationMs, diff --git a/src/screens/settings-screen/__tests__/settings-ui-parity.test.tsx b/src/screens/settings-screen/__tests__/settings-ui-parity.test.tsx index 290cd30..50927d7 100644 --- a/src/screens/settings-screen/__tests__/settings-ui-parity.test.tsx +++ b/src/screens/settings-screen/__tests__/settings-ui-parity.test.tsx @@ -62,8 +62,9 @@ describe.each(implementations)("the %s settings UI", (_platform, SettingsUI) => />, ); - // Android's Compose buttons hold only "−" and "+", so TalkBack reads two unnamed symbols - // with no clue what they change. iOS and web name them; the contract should require it. + // iOS and web both name these. Android does not, and this test cannot see it: Compose + // components need the native runtime, and a TypeScript contract cannot require an + // accessibility label. That gap is documented at the call site in settings-ui.android.tsx. expect(screen.queryByLabelText(/increase/i)).not.toBeNull(); expect(screen.queryByLabelText(/decrease/i)).not.toBeNull(); }); diff --git a/src/screens/settings-screen/settings-screen.tsx b/src/screens/settings-screen/settings-screen.tsx index 9533e77..9a2675a 100644 --- a/src/screens/settings-screen/settings-screen.tsx +++ b/src/screens/settings-screen/settings-screen.tsx @@ -134,7 +134,7 @@ export const SettingsRootScreen: FC< testID="settings.vibration" /> - + - + {patternPresets.map((patternPreset) => { return ( ["name"]; const Section: React.FC> = ({ label, children }) => { const isDarkMode = useColorScheme() === "dark"; + const theme = useThemeColors(); return ( - {label} + {label} {React.Children.map(children, (child, index) => index === 0 || !child ? ( @@ -57,6 +58,7 @@ const BaseItem: FC> = ({ children, }) => { const isDarkMode = useColorScheme() === "dark"; + const theme = useThemeColors(); return ( {(iconName || label) && ( @@ -73,7 +75,11 @@ const BaseItem: FC> = ({ )} {label} - {secondaryLabel && {secondaryLabel}} + {secondaryLabel && ( + + {secondaryLabel} + + )} )} @@ -83,16 +89,17 @@ const BaseItem: FC> = ({ }; export const LinkItem: FC = ({ value, onPress, ...baseProps }) => { + const theme = useThemeColors(); return ( - {value} + {value} @@ -231,6 +238,7 @@ export const RadioButtonItem: FC = ({ ...baseProps }) => { const isDarkMode = useColorScheme() === "dark"; + const theme = useThemeColors(); return ( = ({ {label} - {secondaryLabel} + + {secondaryLabel} + {selected && } @@ -309,9 +319,7 @@ const styles = StyleSheet.create({ alignItems: "center", flexDirection: "row", }, - secondaryText: { - color: colors["slate-500"], - }, + secondaryText: {}, section: { paddingTop: 16, }, @@ -324,7 +332,6 @@ const styles = StyleSheet.create({ }, sectionLabel: { ...fontSizes.xs, - color: colors["slate-500"], marginBottom: 8, paddingHorizontal: 16, textTransform: "uppercase", diff --git a/src/screens/settings-screen/settings-ui.types.ts b/src/screens/settings-screen/settings-ui.types.ts index 1524260..62bbc45 100644 --- a/src/screens/settings-screen/settings-ui.types.ts +++ b/src/screens/settings-screen/settings-ui.types.ts @@ -3,7 +3,9 @@ import type { PropsWithChildren } from "react"; export interface SectionProps { label: string; - hideBottomBorder?: boolean; + // Web only. iOS and Android ignore it — they draw their own section separators. The name + // carries the platform because a TypeScript contract cannot make the other two obey it. + hideBottomBorderWeb?: boolean; } interface CommonItemProps { diff --git a/src/screens/settings-screen/settings-ui.web.tsx b/src/screens/settings-screen/settings-ui.web.tsx index 124c26d..c705433 100644 --- a/src/screens/settings-screen/settings-ui.web.tsx +++ b/src/screens/settings-screen/settings-ui.web.tsx @@ -29,15 +29,15 @@ import { const Section: React.FC> = ({ label, children, - hideBottomBorder, + hideBottomBorderWeb, }) => { const isDarkMode = useColorScheme() === "dark"; return ( diff --git a/src/services/__tests__/audio.test.ts b/src/services/__tests__/audio.test.ts index 9b05d7d..fcad5be 100644 --- a/src/services/__tests__/audio.test.ts +++ b/src/services/__tests__/audio.test.ts @@ -116,20 +116,37 @@ describe("guided breathing audio", () => { expect(mockPlayers[1]!.play).toHaveBeenCalledTimes(1); }); - it("gives the bell mode a different sound for the inhale and the exhale", async () => { + it("cues only the direction changes in the bell mode, with a different sound each way", async () => { await setupGuidedBreathingAudio("bell"); - // Player order follows the setup: ending bell, inhale, exhale, hold. - const [, breatheIn, breatheOut, hold] = mockPlayers; + // Ending bell, inhale, exhale. No hold cue: `buildStepsMetadata` gives the id `hold` to + // both the step after the inhale and the step after the exhale, so a bell there would + // sound twice a cycle and break the alternation — on Square, the default pattern, that + // produced three identical bells in a row. + expect(mockPlayers).toHaveLength(3); - // The bell mode is used with the eyes closed, so the two directions must not - // sound the same. + const [, breatheIn, breatheOut] = mockPlayers; expect(breatheIn!.source).not.toEqual(breatheOut!.source); expect(breatheIn!.source).toEqual({ assetId: 8, uri: "file:///audio/8.mp3" }); expect(breatheOut!.source).toEqual({ assetId: 9, uri: "file:///audio/9.mp3" }); - // Patterns without a hold step must still reach both bells, so the hold reuses - // the inhale bell rather than owning a third sound. - expect(hold!.source).toEqual(breatheIn!.source); + + // A hold cue must be silent rather than reuse a direction bell. + await expect(playGuidedBreathingSound("hold")).resolves.toBeUndefined(); + expect(breatheIn!.play).not.toHaveBeenCalled(); + expect(breatheOut!.play).not.toHaveBeenCalled(); + }); + + it("alternates the bells across a full square cycle", async () => { + await setupGuidedBreathingAudio("bell"); + const [, breatheIn, breatheOut] = mockPlayers; + + // Square is the default pattern: inhale, hold, exhale, hold — then it loops. + for (const step of ["breatheIn", "hold", "breatheOut", "hold", "breatheIn"] as const) { + await playGuidedBreathingSound(step); + } + + expect(breatheIn!.play).toHaveBeenCalledTimes(2); + expect(breatheOut!.play).toHaveBeenCalledTimes(1); }); it("creates only the ending bell player for the disabled mode", async () => { diff --git a/src/services/audio.ts b/src/services/audio.ts index e294939..caf13f1 100644 --- a/src/services/audio.ts +++ b/src/services/audio.ts @@ -34,14 +34,15 @@ const guidedBreathingAudioAssets: GuidedBreathingAudioSounds = { breatheOut: sounds.paulBreatheOut, hold: sounds.paulHold, }, - // The bell mode is used with the eyes closed, so the inhale and the exhale must - // sound different. They also carry the two `hold` steps of a pattern: `hold` reuses - // the inhale bell, because some patterns (`awake`, `coherent`, `extended-exhale`, - // `ujjayi`) have no hold steps at all and would otherwise never play cueBell2. + // The bell mode is used with the eyes closed, so the two directions must not sound the + // same. Only the direction changes are cued: `buildStepsMetadata` gives the id `hold` to + // both the step after the inhale and the step after the exhale, so any bell assigned to it + // sounds twice per cycle and stops the sequence alternating. Silence during a hold is + // unambiguous — the next bell says which way to breathe. bell: { breatheIn: sounds.cueBell1, breatheOut: sounds.cueBell2, - hold: sounds.cueBell1, + hold: undefined, }, disabled: { breatheIn: undefined, @@ -50,8 +51,10 @@ const guidedBreathingAudioAssets: GuidedBreathingAudioSounds = { }, }; +// Partial: a mode may cue only some steps. Bell cues the two direction changes and leaves +// the holds silent; `disabled` cues nothing at all. type CurrentGuidedBreathingSounds = { - [key in GuidedBreathingStep]: AudioPlayer; + [key in GuidedBreathingStep]?: AudioPlayer; }; let currentGuidedBreathingSounds: CurrentGuidedBreathingSounds | undefined; @@ -73,9 +76,9 @@ const disposeCurrentAudio = async () => { endingBellSound = undefined; bellSound?.remove(); - guidedBreathingSounds?.breatheIn.remove(); - guidedBreathingSounds?.breatheOut.remove(); - guidedBreathingSounds?.hold.remove(); + guidedBreathingSounds?.breatheIn?.remove(); + guidedBreathingSounds?.breatheOut?.remove(); + guidedBreathingSounds?.hold?.remove(); }; const prepareAudioSource = async (source: AudioSource): Promise => { @@ -114,14 +117,13 @@ export function setupGuidedBreathingAudio(guidedBreathingMode: GuidedBreathingMo if (audioGeneration !== requestedAudioGeneration) return; endingBellSound = createAudioPlayer(endingBellSource); - // Modes without step cues (e.g. "disabled") keep only the ending bell. - if (breatheInSource != null && breatheOutSource != null && holdSource != null) { - currentGuidedBreathingSounds = { - breatheIn: createAudioPlayer(breatheInSource), - breatheOut: createAudioPlayer(breatheOutSource), - hold: createAudioPlayer(holdSource), - }; - } + // Each cue is built on its own, so a mode can cue some steps and not others. A mode with + // no cues at all (e.g. "disabled") keeps only the ending bell. + const stepPlayers: CurrentGuidedBreathingSounds = {}; + if (breatheInSource != null) stepPlayers.breatheIn = createAudioPlayer(breatheInSource); + if (breatheOutSource != null) stepPlayers.breatheOut = createAudioPlayer(breatheOutSource); + if (holdSource != null) stepPlayers.hold = createAudioPlayer(holdSource); + if (Object.keys(stepPlayers).length > 0) currentGuidedBreathingSounds = stepPlayers; }); } @@ -132,9 +134,9 @@ export const releaseGuidedBreathingAudio = () => { export const stopGuidedBreathingAudio = () => { endingBellSound?.pause(); - currentGuidedBreathingSounds?.breatheIn.pause(); - currentGuidedBreathingSounds?.breatheOut.pause(); - currentGuidedBreathingSounds?.hold.pause(); + currentGuidedBreathingSounds?.breatheIn?.pause(); + currentGuidedBreathingSounds?.breatheOut?.pause(); + currentGuidedBreathingSounds?.hold?.pause(); }; export const playGuidedBreathingSound = async (guidedBreathingStep: GuidedBreathingStep) => { diff --git a/src/stores/settings-state.ts b/src/stores/settings-state.ts index a05cee2..9b43505 100644 --- a/src/stores/settings-state.ts +++ b/src/stores/settings-state.ts @@ -17,7 +17,14 @@ export interface PersistedSettingsState { vibrationEnabled: boolean; } -export const customPatternDurationLimits: [number, number][] = [ +// A tuple, not an array: `normalizePersistedSettingsState` maps over this to build the four +// steps, so its length is what guarantees the result really has four of them. +export const customPatternDurationLimits: [ + [number, number], + [number, number], + [number, number], + [number, number], +] = [ [ms("1 sec"), ms("99 sec")], [0, ms("99 sec")], [ms("1 sec"), ms("99 sec")], diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 141a79d..a140f05 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -18,6 +18,7 @@ import { type Theme, } from "@breathly/stores/settings-state"; import { GuidedBreathingMode } from "@breathly/types/guided-breathing-mode"; +import { delay } from "@breathly/utils/delay"; interface SettingsStore extends PersistedSettingsState { setCustomPatternEnabled: (enabled: boolean) => unknown; @@ -31,6 +32,8 @@ interface SettingsStore extends PersistedSettingsState { setVibrationEnabled: (vibrationEnabled: boolean) => unknown; } +const readRetryDelayMs = 50; + // An unreadable or damaged payload must never stop hydration. Zustand leaves `hasHydrated` // false when the read rejects, `useHydration` then never turns true, and the app renders an // empty view on every launch — with no way back, because the app has no network. Both @@ -39,10 +42,12 @@ interface SettingsStore extends PersistedSettingsState { const settingsStorage: PersistStorage = { getItem: async (name) => { // Falling back to the defaults means the next settings change overwrites whatever is on - // disk. A transient failure — a locked database, say — would then cost the user their - // real settings, so give the read a second chance before giving up on it. + // disk. A transient failure — a briefly locked database, say — would then cost the user + // their real settings, so give the read a second chance, after a pause long enough for + // the lock to clear. Back to back the retry would only survive a bridge hiccup. for (let attempt = 0; attempt < 2; attempt++) { try { + if (attempt > 0) await delay(readRetryDelayMs); const storedValue = await AsyncStorage.getItem(name); if (storedValue == null) return null; return JSON.parse(storedValue) as StorageValue; diff --git a/src/utils/animate.ts b/src/utils/animate.ts index 7e6bd6a..7b2c406 100644 --- a/src/utils/animate.ts +++ b/src/utils/animate.ts @@ -10,8 +10,11 @@ export const defaultEasing = Easing.inOut(Easing.quad); export const animate = (value: Animated.Value, config: Partial) => { return Animated.timing(value, { toValue: config.toValue!, - easing: defaultEasing, ...config, + // Below the spread, but coalesced rather than assigned: a caller that passes an easing + // keeps it, and one that passes an explicit `undefined` still gets the app's curve + // instead of React Native's own default. + easing: config.easing ?? defaultEasing, useNativeDriver: true, }); }; diff --git a/src/utils/use-accessibility-preferences.ts b/src/utils/use-accessibility-preferences.ts index 4bccc42..2b651cc 100644 --- a/src/utils/use-accessibility-preferences.ts +++ b/src/utils/use-accessibility-preferences.ts @@ -8,6 +8,28 @@ import { AccessibilityInfo, Platform } from "react-native"; let lastKnownReduceMotionEnabled = false; let lastKnownScreenReaderEnabled = false; +// Asked at module load rather than on first mount, so the answer is already in flight before +// anything renders. The first consumer would otherwise draw a frame of the motion the user +// asked the system to remove. +const initialReduceMotionQuery: Promise = AccessibilityInfo.isReduceMotionEnabled() + .then((enabled) => { + lastKnownReduceMotionEnabled = enabled; + return enabled; + }) + .catch(() => false); + +// `react-native-web` answers "true" to this question in every browser, thus the app asks it +// only on the two mobile platforms. +const initialScreenReaderQuery: Promise = + Platform.OS === "web" + ? Promise.resolve(false) + : AccessibilityInfo.isScreenReaderEnabled() + .then((enabled) => { + lastKnownScreenReaderEnabled = enabled; + return enabled; + }) + .catch(() => false); + // The exercise rotates and translates eight circles (sixteen in dark mode) // across the width of the screen for the whole session. That is the motion // profile that starts vestibular symptoms, thus the system setting must remove @@ -18,14 +40,19 @@ export const useReduceMotion = () => { useEffect(() => { let active = true; + // A change event that lands while the initial query is still in flight is the fresher + // answer. Without this the stale one would win and, because the value is cached at + // module scope, poison every later mount in the process. + let answered = false; const applyAnswer = (enabled: boolean) => { + answered = true; lastKnownReduceMotionEnabled = enabled; if (active) setReduceMotionEnabled(enabled); }; - AccessibilityInfo.isReduceMotionEnabled() - .then(applyAnswer) - .catch(() => undefined); + void initialReduceMotionQuery.then((enabled) => { + if (!answered) applyAnswer(enabled); + }); const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", applyAnswer); return () => { @@ -43,19 +70,19 @@ export const useScreenReaderEnabled = () => { const [screenReaderEnabled, setScreenReaderEnabled] = useState(lastKnownScreenReaderEnabled); useEffect(() => { - // `react-native-web` answers "true" to this question in every browser, thus - // the app asks it only on the two mobile platforms. if (Platform.OS === "web") return; let active = true; + let answered = false; const applyAnswer = (enabled: boolean) => { + answered = true; lastKnownScreenReaderEnabled = enabled; if (active) setScreenReaderEnabled(enabled); }; - AccessibilityInfo.isScreenReaderEnabled() - .then(applyAnswer) - .catch(() => undefined); + void initialScreenReaderQuery.then((enabled) => { + if (!answered) applyAnswer(enabled); + }); const subscription = AccessibilityInfo.addEventListener("screenReaderChanged", applyAnswer); return () => {