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
3 changes: 3 additions & 0 deletions .maestro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ maestro test .maestro/flows/settings-persistence.yaml
Each top-level flow resets application data so it can run independently. The settings flow also
kills and relaunches the app without clearing data to verify AsyncStorage persistence.

The completion flow sets the shortest time limit and then waits for the exercise to end by itself.
It runs for approximately 90 seconds, thus it is much slower than the other flows.

## Cross-version visual comparison

Install the `master` or candidate release build, then capture its light and dark screenshots:
Expand Down
48 changes: 48 additions & 0 deletions .maestro/flows/exercise-completion.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
appId: com.mmazzarolo.breathly
name: Exercise completes after the last exhale
---
- clearState
- launchApp
- extendedWaitUntil:
visible:
id: "home.screen"
timeout: 10000
- tapOn:
id: "home.customize"
- extendedWaitUntil:
visible:
id: "settings.screen"
timeout: 5000
# The default limit of two minutes makes this flow unnecessarily slow.
- scrollUntilVisible:
element:
id: "settings.timer.decrease"
direction: DOWN
- tapOn:
id: "settings.timer.decrease"
- assertVisible:
id: "settings.timer.value"
text: "1"
- runFlow:
file: ../subflows/close-settings.yaml
- tapOn:
id: "home.start-session"
- extendedWaitUntil:
visible:
id: "exercise.running"
timeout: 8000
# The time limit does not stop the exercise immediately: the exercise continues
# to the end of the current exhale. With the default pattern this adds a maximum
# of twelve seconds to the minute of the time limit. The timeout is much longer,
# because a slow emulator makes the exercise clock run behind the real time.
- extendedWaitUntil:
visible:
id: "exercise.complete"
timeout: 180000
- assertVisible: "Complete"
- tapOn:
id: "exercise.close"
- extendedWaitUntil:
visible:
id: "home.screen"
timeout: 5000
34 changes: 34 additions & 0 deletions src/screens/exercise-screen/__tests__/exercise-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createExerciseSession,
exerciseSessionReducer,
getActiveTickDeltaMs,
getExerciseStepTransition,
} from "../exercise-session";

describe("exercise session lifecycle", () => {
Expand Down Expand Up @@ -76,3 +77,36 @@ describe("exercise session lifecycle", () => {
expect(invalidResume).toBe(initial);
});
});

describe("exercise step transitions", () => {
it("announces the first step of the exercise", () => {
expect(getExerciseStepTransition(undefined, "inhale", false)).toBe("startStep");
});

it("does nothing when the loop repeats the same step", () => {
expect(getExerciseStepTransition("exhale", "exhale", false)).toBe("none");
expect(getExerciseStepTransition("exhale", "exhale", true)).toBe("none");
});

it("announces every step while the time limit is not reached", () => {
expect(getExerciseStepTransition("inhale", "afterInhale", false)).toBe("startStep");
expect(getExerciseStepTransition("afterInhale", "exhale", false)).toBe("startStep");
expect(getExerciseStepTransition("exhale", "afterExhale", false)).toBe("startStep");
expect(getExerciseStepTransition("afterExhale", "inhale", false)).toBe("startStep");
});

it("stops the exercise at the end of the exhale after the time limit", () => {
expect(getExerciseStepTransition("exhale", "afterExhale", true)).toBe("complete");
expect(getExerciseStepTransition("exhale", "inhale", true)).toBe("complete");
});

it("stops the exercise at the end of the hold that follows the exhale", () => {
expect(getExerciseStepTransition("afterExhale", "inhale", true)).toBe("complete");
});

it("keeps breathing after the time limit until the lungs are empty", () => {
expect(getExerciseStepTransition("inhale", "afterInhale", true)).toBe("startStep");
expect(getExerciseStepTransition("inhale", "exhale", true)).toBe("startStep");
expect(getExerciseStepTransition("afterInhale", "exhale", true)).toBe("startStep");
});
});
57 changes: 38 additions & 19 deletions src/screens/exercise-screen/exercise-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AnimatedDots } from "@breathly/screens/exercise-screen/animated-dots";
import {
createExerciseSession,
exerciseSessionReducer,
getExerciseStepTransition,
type ResumableExerciseStatus,
} from "@breathly/screens/exercise-screen/exercise-session";
import { StepDescription } from "@breathly/screens/exercise-screen/step-description";
Expand Down Expand Up @@ -68,7 +69,7 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
[playExerciseStepAudio]
);

const handleTimeLimitReached = useCallback(() => {
const handleExerciseComplete = useCallback(() => {
playExerciseCompletedAudio();
dispatchSession({ type: "complete", activeElapsedMs: activeElapsedMs.current });
}, [playExerciseCompletedAudio]);
Expand Down Expand Up @@ -104,7 +105,7 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
<StarsBackground size={widestDeviceDimension * 0.8} fadeIn={true} />
)}
<ExerciseRunningFragment
onTimeLimitReached={handleTimeLimitReached}
onComplete={handleExerciseComplete}
onStepChange={handleExerciseStepChange}
onStepIndexChange={handleStepIndexChange}
initialActiveElapsedMs={session.activeElapsedMs}
Expand Down Expand Up @@ -133,7 +134,7 @@ export const ExerciseScreen: FC<NativeStackScreenProps<RootStackParamList, "Exer
};

interface ExerciseRunningFragmentProps {
onTimeLimitReached: () => unknown;
onComplete: () => unknown;
onStepChange: (stepMetadata: StepMetadata) => unknown;
onStepIndexChange: (stepIndex: number) => void;
initialActiveElapsedMs: number;
Expand All @@ -144,7 +145,7 @@ interface ExerciseRunningFragmentProps {
const unmountAnimDuration = 300;

const ExerciseRunningFragment: FC<ExerciseRunningFragmentProps> = ({
onTimeLimitReached,
onComplete,
onStepChange,
onStepIndexChange,
initialActiveElapsedMs,
Expand All @@ -167,30 +168,48 @@ const ExerciseRunningFragment: FC<ExerciseRunningFragmentProps> = ({

useKeepAwake();

const playStepHaptic = useExerciseHaptics(vibrationEnabled);

// The time limit does not stop the exercise on its own: it only arms the
// completion. The step transition below then stops the exercise at the end of
// the first step that leaves the lungs empty.
const timeLimitReachedRef = useRef(false);
const completionStartedRef = useRef(false);

const startCompletion = () => {
if (completionStartedRef.current) return;
completionStartedRef.current = true;
animate(unmountContentAnimVal, {
toValue: 0,
duration: unmountAnimDuration,
}).start(({ finished }) => {
if (finished) {
onComplete();
}
});
};

useOnUpdate(
(prevStepMetadata) => {
if (prevStepMetadata?.id !== currentStep.id) {
const transition = getExerciseStepTransition(
prevStepMetadata?.id,
currentStep.id,
timeLimitReachedRef.current
);
if (transition === "complete") {
startCompletion();
} else if (transition === "startStep") {
onStepChange(currentStep);
playStepHaptic();
}
},
currentStep,
true
);

useExerciseHaptics(currentStep, vibrationEnabled);

const unmountContentAnimation = animate(unmountContentAnimVal, {
toValue: 0,
duration: unmountAnimDuration,
});

const handleTimeLimitReached = () => {
unmountContentAnimation.start(({ finished }) => {
if (finished) {
onTimeLimitReached();
}
});
};
const handleTimeLimitReached = useCallback(() => {
timeLimitReachedRef.current = true;
}, []);

const contentAnimatedStyle = {
opacity: unmountContentAnimVal,
Expand Down
23 changes: 23 additions & 0 deletions src/screens/exercise-screen/exercise-session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { StepId } from "@breathly/types/step-metadata";

export type ResumableExerciseStatus = "interlude" | "running";
export type ExerciseStatus = ResumableExerciseStatus | "paused" | "completed";

Expand Down Expand Up @@ -34,6 +36,27 @@ export const getActiveTickDeltaMs = (
return tickDeltaMs;
};

export type ExerciseStepTransition = "none" | "startStep" | "complete";

// The exercise must not stop while the lungs are full: the user would then hold
// the breath while the completion screen appears. These two steps end with empty
// lungs, thus they are the only safe points at which the exercise can stop.
const endsWithEmptyLungs = (stepId: StepId | undefined) =>
stepId === "exhale" || stepId === "afterExhale";

// The time limit usually occurs in the middle of a step. The exercise then
// continues to the first step boundary that leaves the lungs empty, and it stops
// there. This adds at most one inhale, one hold and one exhale to the session.
export const getExerciseStepTransition = (
previousStepId: StepId | undefined,
currentStepId: StepId,
timeLimitReached: boolean
): ExerciseStepTransition => {
if (previousStepId === currentStepId) return "none";
if (timeLimitReached && endsWithEmptyLungs(previousStepId)) return "complete";
return "startStep";
};

export const exerciseSessionReducer = (
session: ExerciseSession,
action: ExerciseSessionAction
Expand Down
43 changes: 26 additions & 17 deletions src/screens/exercise-screen/timer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
import { animate } from "@breathly/utils/animate";
import { formatTimer } from "@breathly/utils/format-timer";
import { useInterval } from "@breathly/utils/use-interval";
import { useOnMount } from "@breathly/utils/use-on-mount";

type Props = {
limit: number;
Expand All @@ -18,6 +17,8 @@ type Props = {

const timerRefreshIntervalMs = 250;
const maximumActiveTickGapMs = timerRefreshIntervalMs * 4;
const showAnimDuration = 500;
const hideAnimDuration = 400;

export const Timer: FC<Props> = ({
limit,
Expand Down Expand Up @@ -50,31 +51,32 @@ export const Timer: FC<Props> = ({
onActiveElapsedChange(nextElapsedTimeMs);
}, timerRefreshIntervalMs);

const showContainerAnimation = animate(opacityAnimVal, {
toValue: 1,
});
const remainingTimeMs = limit ? Math.max(0, limit - elapsedTimeMs) : undefined;
const limitReached = remainingTimeMs === 0;

useOnMount(() => {
showContainerAnimation.start();
// The exercise continues until the end of the current exhale, thus the timer
// stays at 00:00 for some seconds. It fades away instead: the clock is
// complete, and only the last breath remains.
useEffect(() => {
const containerAnimation = animate(opacityAnimVal, {
toValue: limitReached ? 0 : 1,
duration: limitReached ? hideAnimDuration : showAnimDuration,
});
containerAnimation.start();
return () => {
showContainerAnimation.stop();
containerAnimation.stop();
};
});

const remainingTimeMs = limit ? Math.max(0, limit - elapsedTimeMs) : undefined;
}, [limitReached, opacityAnimVal]);

useEffect(() => {
if (remainingTimeMs === 0 && !limitReachedRef.current) {
if (limitReached && !limitReachedRef.current) {
limitReachedRef.current = true;
onLimitReached();
}
}, [onLimitReached, remainingTimeMs]);
}, [limitReached, onLimitReached]);

const containerAnimatedStyle = {
opacity: opacityAnimVal.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
opacity: opacityAnimVal,
};

const timerText =
Expand All @@ -83,7 +85,14 @@ export const Timer: FC<Props> = ({
: formatTimer(Math.ceil(remainingTimeMs / 1000));

return (
<Animated.View className="mt-4" style={containerAnimatedStyle}>
<Animated.View
className="mt-4"
style={containerAnimatedStyle}
// The exercise continues after the timer fades away. Keep the invisible
// 00:00 out of the accessibility tree while the last breath continues.
accessibilityElementsHidden={limitReached}
importantForAccessibility={limitReached ? "no-hide-descendants" : "auto"}
>
<Animated.Text
className="text-center text-2xl text-slate-800 dark:text-white"
style={{ fontVariant: ["tabular-nums"] }}
Expand Down
39 changes: 15 additions & 24 deletions src/screens/exercise-screen/use-exercise-haptics.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,18 @@
import * as Haptics from "expo-haptics";
import { useCallback } from "react";
import { Platform, Vibration } from "react-native";
import { StepMetadata } from "@breathly/types/step-metadata";
import { useOnUpdate } from "@breathly/utils/use-on-update";

export const useExerciseHaptics = (
currentStepMetadata: StepMetadata,
vibrationEnabled: boolean
) => {
useOnUpdate(
(prevStepMetadata) => {
if (currentStepMetadata?.id !== prevStepMetadata?.id) {
if (vibrationEnabled) {
if (Platform.OS === "ios") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
} else if (Platform.OS === "android") {
// `expo-haptics` doesn't provide a vibration pattern "soft" enough for my tastes on
// Android so I fallback to the Vibration API.
Vibration.vibrate(100);
}
}
}
},
currentStepMetadata,
true
);
};
// Returns the cue that the exercise plays at the start of each breathing step.
// The caller decides when to play it: the step that follows the completion of
// the exercise must not cue the user to breathe again.
export const useExerciseHaptics = (vibrationEnabled: boolean) =>
useCallback(() => {
if (!vibrationEnabled) return;
if (Platform.OS === "ios") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
} else if (Platform.OS === "android") {
// `expo-haptics` doesn't provide a vibration pattern "soft" enough for my tastes on
// Android so I fallback to the Vibration API.
Vibration.vibrate(100);
}
}, [vibrationEnabled]);
4 changes: 3 additions & 1 deletion src/types/step-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { GuidedBreathingStep } from "@breathly/types/guided-breathing-step";

export type StepId = "inhale" | "afterInhale" | "exhale" | "afterExhale";

export interface StepMetadata {
id: string;
id: StepId;
audioId: GuidedBreathingStep;
label: string;
duration: number;
Expand Down
Loading