diff --git a/.maestro/README.md b/.maestro/README.md index 07dc4f2..a1f3358 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -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: diff --git a/.maestro/flows/exercise-completion.yaml b/.maestro/flows/exercise-completion.yaml new file mode 100644 index 0000000..978342c --- /dev/null +++ b/.maestro/flows/exercise-completion.yaml @@ -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 diff --git a/src/screens/exercise-screen/__tests__/exercise-session.test.ts b/src/screens/exercise-screen/__tests__/exercise-session.test.ts index d77ca95..e26b613 100644 --- a/src/screens/exercise-screen/__tests__/exercise-session.test.ts +++ b/src/screens/exercise-screen/__tests__/exercise-session.test.ts @@ -2,6 +2,7 @@ import { createExerciseSession, exerciseSessionReducer, getActiveTickDeltaMs, + getExerciseStepTransition, } from "../exercise-session"; describe("exercise session lifecycle", () => { @@ -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"); + }); +}); diff --git a/src/screens/exercise-screen/exercise-screen.tsx b/src/screens/exercise-screen/exercise-screen.tsx index 70609f2..5d83c6c 100644 --- a/src/screens/exercise-screen/exercise-screen.tsx +++ b/src/screens/exercise-screen/exercise-screen.tsx @@ -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"; @@ -68,7 +69,7 @@ export const ExerciseScreen: FC { + const handleExerciseComplete = useCallback(() => { playExerciseCompletedAudio(); dispatchSession({ type: "complete", activeElapsedMs: activeElapsedMs.current }); }, [playExerciseCompletedAudio]); @@ -104,7 +105,7 @@ export const ExerciseScreen: FC )} unknown; + onComplete: () => unknown; onStepChange: (stepMetadata: StepMetadata) => unknown; onStepIndexChange: (stepIndex: number) => void; initialActiveElapsedMs: number; @@ -144,7 +145,7 @@ interface ExerciseRunningFragmentProps { const unmountAnimDuration = 300; const ExerciseRunningFragment: FC = ({ - onTimeLimitReached, + onComplete, onStepChange, onStepIndexChange, initialActiveElapsedMs, @@ -167,30 +168,48 @@ const ExerciseRunningFragment: FC = ({ 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, diff --git a/src/screens/exercise-screen/exercise-session.ts b/src/screens/exercise-screen/exercise-session.ts index a9ff69a..f5f2c44 100644 --- a/src/screens/exercise-screen/exercise-session.ts +++ b/src/screens/exercise-screen/exercise-session.ts @@ -1,3 +1,5 @@ +import type { StepId } from "@breathly/types/step-metadata"; + export type ResumableExerciseStatus = "interlude" | "running"; export type ExerciseStatus = ResumableExerciseStatus | "paused" | "completed"; @@ -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 diff --git a/src/screens/exercise-screen/timer.tsx b/src/screens/exercise-screen/timer.tsx index a9cd505..27b4276 100644 --- a/src/screens/exercise-screen/timer.tsx +++ b/src/screens/exercise-screen/timer.tsx @@ -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; @@ -18,6 +17,8 @@ type Props = { const timerRefreshIntervalMs = 250; const maximumActiveTickGapMs = timerRefreshIntervalMs * 4; +const showAnimDuration = 500; +const hideAnimDuration = 400; export const Timer: FC = ({ limit, @@ -50,31 +51,32 @@ export const Timer: FC = ({ 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 = @@ -83,7 +85,14 @@ export const Timer: FC = ({ : formatTimer(Math.ceil(remainingTimeMs / 1000)); return ( - + { - 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]); diff --git a/src/types/step-metadata.ts b/src/types/step-metadata.ts index 6f79d9c..334af68 100644 --- a/src/types/step-metadata.ts +++ b/src/types/step-metadata.ts @@ -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;