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
13 changes: 12 additions & 1 deletion src/core/entry-point.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/screens/exercise-screen/accessibility-announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ export const getInterludeAccessibilityLabel = (secondsLeft: number) =>
`Starting session in ${secondsLeft}`;

export const sessionCompleteAnnouncement = "Session complete";

export const sessionPausedAnnouncement = "Session paused";
39 changes: 33 additions & 6 deletions src/screens/exercise-screen/exercise-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -65,11 +67,6 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
const colorScheme = useColorScheme();
const theme = useThemeColors();

// The countdown, the paused screen and the completion screen need the screen
// awake as much as the exercise does: a screen that locks during the countdown
// pauses the session before it starts.
useKeepAwake();

const { playExerciseStepAudio, playExerciseCompletedAudio, stopExerciseAudio } = useExerciseAudio(
effectiveGuidedBreathingVoice,
);
Expand All @@ -83,7 +80,14 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
if (nextAppState === "background") {
stopExerciseAudio();
dispatchSession({ type: "pause", activeElapsedMs: activeElapsedMs.current });
return;
}

// Coming back, silence anything expo-audio resumed on its own. It pauses the players
// it interrupted and replays them afterwards, and the JS AppState event arrives after
// its native observers have already run — so a cue caught mid-word would otherwise
// finish in the middle of the wrong step, seconds or minutes later.
if (nextAppState === "active") stopExerciseAudio();
});

return () => subscription.remove();
Expand Down Expand Up @@ -151,6 +155,11 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
<ExercisePaused resumeStatus={session.resumeStatus} onResume={handleResume} />
)}
{session.status === "completed" && <ExerciseComplete />}
{/* 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" && <KeepDisplayAwake />}
<View style={styles.closeButtonRow}>
<Pressable
style={[styles.closeButton, { borderColor: theme.control }]}
Expand All @@ -166,6 +175,12 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
);
};

// `useKeepAwake` releases on unmount, so mounting it conditionally is what scopes it.
const KeepDisplayAwake: FC = () => {
useKeepAwake();
return null;
};

interface ExerciseRunningFragmentProps {
onComplete: () => unknown;
onStepChange: (stepMetadata: StepMetadata) => unknown;
Expand Down Expand Up @@ -288,9 +303,21 @@ interface ExercisePausedProps {
const ExercisePaused: FC<ExercisePausedProps> = ({ 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 (
<View style={styles.pausedScreen} testID="exercise.paused">
<Text style={[styles.pausedTitle, isDarkMode && styles.pausedTitleDark]}>Paused</Text>
<Text
accessibilityRole="header"
style={[styles.pausedTitle, isDarkMode && styles.pausedTitleDark]}
>
Paused
</Text>
<Text style={[styles.pausedDescription, { color: theme.textSecondary }]}>
{resumeStatus === "interlude"
? "The starting countdown was interrupted."
Expand Down
29 changes: 14 additions & 15 deletions src/screens/exercise-screen/step-animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
4 changes: 2 additions & 2 deletions src/screens/settings-screen/settings-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export const SettingsRootScreen: FC<
testID="settings.vibration"
/>
</SettingsUI.Section>
<SettingsUI.Section label="Timer" hideBottomBorder>
<SettingsUI.Section label="Timer" hideBottomBorderWeb>
<SettingsUI.StepperItem
label="Exercise timer"
secondaryLabel="Time limit in minutes"
Expand Down Expand Up @@ -217,7 +217,7 @@ export const SettingsPatternPickerScreen: FC<
);
})}
</SettingsUI.Section>
<SettingsUI.Section label="Pattern presets" hideBottomBorder>
<SettingsUI.Section label="Pattern presets" hideBottomBorderWeb>
{patternPresets.map((patternPreset) => {
return (
<SettingsUI.RadioButtonItem
Expand Down
27 changes: 17 additions & 10 deletions src/screens/settings-screen/settings-ui.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, { FC, PropsWithChildren, useState } from "react";
import { LayoutAnimation, StyleSheet, Switch, Text, View, ViewStyle } from "react-native";
import { Pressable } from "@breathly/common/pressable";
import { colors } from "@breathly/design/colors";
import { useColorScheme } from "@breathly/design/theme";
import { useColorScheme, useThemeColors } from "@breathly/design/theme";
import { fontFamilies, fontSizes } from "@breathly/design/typography";
import {
LinkItemProps,
Expand All @@ -20,9 +20,10 @@ type IoniconName = React.ComponentProps<typeof Ionicons>["name"];

const Section: React.FC<PropsWithChildren<SectionProps>> = ({ label, children }) => {
const isDarkMode = useColorScheme() === "dark";
const theme = useThemeColors();
return (
<View style={styles.section}>
<Text style={styles.sectionLabel}>{label}</Text>
<Text style={[styles.sectionLabel, { color: theme.textSecondary }]}>{label}</Text>
<View style={[styles.sectionCard, isDarkMode && styles.sectionCardDark]}>
{React.Children.map(children, (child, index) =>
index === 0 || !child ? (
Expand Down Expand Up @@ -57,6 +58,7 @@ const BaseItem: FC<PropsWithChildren<BaseItemProps>> = ({
children,
}) => {
const isDarkMode = useColorScheme() === "dark";
const theme = useThemeColors();
return (
<View style={styles.item}>
{(iconName || label) && (
Expand All @@ -73,7 +75,11 @@ const BaseItem: FC<PropsWithChildren<BaseItemProps>> = ({
)}
<View style={styles.column}>
<Text style={isDarkMode && styles.textDark}>{label}</Text>
{secondaryLabel && <Text style={styles.secondaryText}>{secondaryLabel}</Text>}
{secondaryLabel && (
<Text style={[styles.secondaryText, { color: theme.textSecondary }]}>
{secondaryLabel}
</Text>
)}
</View>
</View>
)}
Expand All @@ -83,16 +89,17 @@ const BaseItem: FC<PropsWithChildren<BaseItemProps>> = ({
};

export const LinkItem: FC<LinkItemProps> = ({ value, onPress, ...baseProps }) => {
const theme = useThemeColors();
return (
<Pressable onPress={onPress} testID={baseProps.testID} accessibilityRole="button">
<BaseItem {...baseProps}>
<View style={styles.row}>
<Text style={styles.secondaryText}>{value}</Text>
<Text style={[styles.secondaryText, { color: theme.textSecondary }]}>{value}</Text>
<Ionicons
style={{ padding: 4 }}
name={"chevron-forward"}
size={18}
color={colors["slate-500"]}
color={theme.textSecondary}
/>
</View>
</BaseItem>
Expand Down Expand Up @@ -231,6 +238,7 @@ export const RadioButtonItem: FC<RadioButtonItemProps> = ({
...baseProps
}) => {
const isDarkMode = useColorScheme() === "dark";
const theme = useThemeColors();
return (
<BaseItem {...baseProps}>
<Pressable
Expand All @@ -246,7 +254,9 @@ export const RadioButtonItem: FC<RadioButtonItemProps> = ({
<View style={[styles.radioContent, { opacity: disabled ? 0.5 : 1 }]}>
<View style={styles.radioLabels}>
<Text style={isDarkMode && styles.textDark}>{label}</Text>
<Text style={styles.secondaryText}>{secondaryLabel}</Text>
<Text style={[styles.secondaryText, { color: theme.textSecondary }]}>
{secondaryLabel}
</Text>
</View>
<View style={styles.radioCheck}>
{selected && <Ionicons name={"checkmark-sharp"} size={18} color={colors["blue-500"]} />}
Expand Down Expand Up @@ -309,9 +319,7 @@ const styles = StyleSheet.create({
alignItems: "center",
flexDirection: "row",
},
secondaryText: {
color: colors["slate-500"],
},
secondaryText: {},
section: {
paddingTop: 16,
},
Expand All @@ -324,7 +332,6 @@ const styles = StyleSheet.create({
},
sectionLabel: {
...fontSizes.xs,
color: colors["slate-500"],
marginBottom: 8,
paddingHorizontal: 16,
textTransform: "uppercase",
Expand Down
4 changes: 3 additions & 1 deletion src/screens/settings-screen/settings-ui.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions src/screens/settings-screen/settings-ui.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ import {
const Section: React.FC<PropsWithChildren<SectionProps>> = ({
label,
children,
hideBottomBorder,
hideBottomBorderWeb,
}) => {
const isDarkMode = useColorScheme() === "dark";
return (
<View
style={[
styles.section,
!hideBottomBorder && styles.sectionBorder,
!hideBottomBorder && isDarkMode && styles.sectionBorderDark,
!hideBottomBorderWeb && styles.sectionBorder,
!hideBottomBorderWeb && isDarkMode && styles.sectionBorderDark,
]}
>
<View style={styles.sectionBody}>
Expand Down
33 changes: 25 additions & 8 deletions src/services/__tests__/audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading