diff --git a/src/features/learning-plans/analysis-orbit-loader.test.ts b/src/features/learning-plans/analysis-orbit-loader.test.ts new file mode 100644 index 00000000..00db92ec --- /dev/null +++ b/src/features/learning-plans/analysis-orbit-loader.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; +import { + ANALYSIS_ORBIT_CENTER, + ANALYSIS_ORBIT_LOADER_SIZE, + ANALYSIS_ORBIT_PETAL_DISTANCE, + ANALYSIS_ORBIT_PETAL_SIZE, + ANALYSIS_ORBIT_PETALS, + getAnalysisOrbitPetalPosition, +} from "./analysis-orbit-loader"; + +describe("analysis orbit loader geometry", () => { + test("folds every petal into one centered circle", () => { + for (const petal of ANALYSIS_ORBIT_PETALS) { + expect(getAnalysisOrbitPetalPosition(petal, 0)).toEqual({ + cx: ANALYSIS_ORBIT_CENTER, + cy: ANALYSIS_ORBIT_CENTER, + }); + } + }); + + test("expands every petal back to its orbit position", () => { + for (const petal of ANALYSIS_ORBIT_PETALS) { + const position = getAnalysisOrbitPetalPosition(petal, 1); + expect(position.cx).toBeCloseTo(petal.cx); + expect(position.cy).toBeCloseTo(petal.cy); + } + }); + + test("opens wider while staying inside the loader bounds", () => { + expect(ANALYSIS_ORBIT_PETAL_DISTANCE).toBeGreaterThan(64); + + const petalRadius = ANALYSIS_ORBIT_PETAL_SIZE / 2; + for (const petal of ANALYSIS_ORBIT_PETALS) { + expect(petal.cx - petalRadius).toBeGreaterThanOrEqual(0); + expect(petal.cx + petalRadius).toBeLessThanOrEqual( + ANALYSIS_ORBIT_LOADER_SIZE, + ); + expect(petal.cy - petalRadius).toBeGreaterThanOrEqual(0); + expect(petal.cy + petalRadius).toBeLessThanOrEqual( + ANALYSIS_ORBIT_LOADER_SIZE, + ); + } + }); +}); diff --git a/src/features/learning-plans/analysis-orbit-loader.ts b/src/features/learning-plans/analysis-orbit-loader.ts new file mode 100644 index 00000000..79b5ac26 --- /dev/null +++ b/src/features/learning-plans/analysis-orbit-loader.ts @@ -0,0 +1,51 @@ +export const ANALYSIS_ORBIT_LOADER_SIZE = 360; +export const ANALYSIS_ORBIT_PETAL_SIZE = 174; + +// Expanded-state distance from the loader center to each petal center. +// Keep this below `loaderRadius - petalRadius` so petals never clip, and below +// the petal radius so the expanded flower still overlaps in the middle instead +// of opening a hole. This is the one design knob for "more or less expanded." +export const ANALYSIS_ORBIT_PETAL_DISTANCE = 86; +export const ANALYSIS_ORBIT_CENTER = ANALYSIS_ORBIT_LOADER_SIZE / 2; + +export type AnalysisOrbitPetal = { + id: string; + cx: number; + cy: number; +}; + +export const ANALYSIS_ORBIT_PETALS: AnalysisOrbitPetal[] = Array.from( + { length: 9 }, + (_, index) => { + // Nine petals at 40-degree increments gives a full 360-degree flower. + // These expanded positions are static, so they are calculated once at + // module load rather than recomputed during every animation frame. + const angle = (index * 40 * Math.PI) / 180; + return { + id: `analysis-orbit-${index}`, + cx: + ANALYSIS_ORBIT_CENTER + Math.sin(angle) * ANALYSIS_ORBIT_PETAL_DISTANCE, + cy: + ANALYSIS_ORBIT_CENTER - Math.cos(angle) * ANALYSIS_ORBIT_PETAL_DISTANCE, + }; + }, +); + +export const getAnalysisOrbitPetalPosition = ( + petal: AnalysisOrbitPetal, + foldProgress: number, +) => { + "worklet"; + + // `foldProgress` is intentionally geometry-only: + // - 1 means fully expanded at the petal's orbit position. + // - 0 means fully folded, with every petal centered on the exact same point. + // + // That is what makes the loader collapse to one circle instead of shrinking + // the whole flower down to a smaller, still-expanded flower. + const progress = Math.min(Math.max(foldProgress, 0), 1); + return { + cx: ANALYSIS_ORBIT_CENTER + (petal.cx - ANALYSIS_ORBIT_CENTER) * progress, + cy: ANALYSIS_ORBIT_CENTER + (petal.cy - ANALYSIS_ORBIT_CENTER) * progress, + }; +}; diff --git a/src/features/learning-plans/learning-plan-ui.tsx b/src/features/learning-plans/learning-plan-ui.tsx index d7606476..38e5dd1c 100644 --- a/src/features/learning-plans/learning-plan-ui.tsx +++ b/src/features/learning-plans/learning-plan-ui.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import Animated, { cancelAnimation, Easing, + type SharedValue, useAnimatedStyle, useReducedMotion, useSharedValue, @@ -11,7 +12,6 @@ import Animated, { withSequence, withTiming, } from "react-native-reanimated"; -import Svg, { Circle } from "react-native-svg"; import { Button } from "~/components/ui/button"; import { FieldAccessory, @@ -33,6 +33,14 @@ import type { PlanSession, SessionPhase, } from "~/features/learning-plans/types"; +import { + ANALYSIS_ORBIT_CENTER, + ANALYSIS_ORBIT_LOADER_SIZE, + type AnalysisOrbitPetal, + ANALYSIS_ORBIT_PETAL_SIZE, + ANALYSIS_ORBIT_PETALS, + getAnalysisOrbitPetalPosition, +} from "~/features/learning-plans/analysis-orbit-loader"; import { formatDate, formatDayOfMonth, @@ -41,6 +49,7 @@ import { parseDateKey, timeFromMinutes, } from "~/features/learning-plans/utils"; +import { DAYOVA_DESIGN_SYSTEM } from "~/lib/design-system"; import { formatGermanUiText } from "~/lib/german-ui-text"; import { formatFileSize } from "~/lib/upload-policy"; @@ -61,23 +70,16 @@ const getSessionPhaseLabel = (phase: SessionPhase) => const sessionPhaseOptions: SessionPhase[] = ["theory", "practice", "rehearsal"]; -const ANALYSIS_ORBIT_LOADER_SIZE = 360; -const ANALYSIS_ORBIT_PETAL_SIZE = 174; -const ANALYSIS_ORBIT_PETAL_DISTANCE = 64; const ORBIT_COLLAPSE_DURATION = 2400; const ORBIT_EXPAND_DURATION = 2200; const ORBIT_CYCLE_DURATION = 5000; const ORBIT_REST_DURATION = ORBIT_CYCLE_DURATION - ORBIT_COLLAPSE_DURATION - ORBIT_EXPAND_DURATION; -const ANALYSIS_ORBIT_CENTER = ANALYSIS_ORBIT_LOADER_SIZE / 2; -const ANALYSIS_ORBIT_PETALS = Array.from({ length: 9 }, (_, index) => { - const angle = (index * 40 * Math.PI) / 180; - return { - id: `analysis-orbit-${index}`, - cx: ANALYSIS_ORBIT_CENTER + Math.sin(angle) * ANALYSIS_ORBIT_PETAL_DISTANCE, - cy: ANALYSIS_ORBIT_CENTER - Math.cos(angle) * ANALYSIS_ORBIT_PETAL_DISTANCE, - }; -}); + +// The design-system primary color is fully saturated, so opacity gives the +// overlapping petals the translucent flower look without introducing a separate +// off-palette blue. +const ORBIT_PETAL_OPACITY = 0.58; export function SectionTitle({ title, @@ -389,21 +391,68 @@ export function SessionEditForm({ ); } +function AnalysisOrbitPetalCircle({ + petal, + foldProgress, +}: { + petal: AnalysisOrbitPetal; + foldProgress: SharedValue; +}) { + const petalStyle = useAnimatedStyle(() => { + // This worklet runs on the Reanimated UI thread. React does not re-render + // during the loader loop; each petal only receives a cheap transform update. + const position = getAnalysisOrbitPetalPosition(petal, foldProgress.get()); + return { + transform: [ + { translateX: position.cx - ANALYSIS_ORBIT_CENTER }, + { translateY: position.cy - ANALYSIS_ORBIT_CENTER }, + ], + }; + }); + + return ( + + ); +} + export function AnalysisOrbitLoader() { - const flowerScale = useSharedValue(1); + // Two independent shared values model the product requirement directly: + // `foldProgress` controls the petal geometry, while `flowerRotation` controls + // only the collapse spin. Keeping them separate avoids rotating the expansion. + const foldProgress = useSharedValue(1); const flowerRotation = useSharedValue(0); const reduceMotion = useReducedMotion(); useEffect(() => { if (reduceMotion) { flowerRotation.set(0); - flowerScale.set(1); + foldProgress.set(1); return; } flowerRotation.set( withRepeat( withSequence( + // Rotate only while collapsing. The following delay keeps the final + // rotation value during expansion/rest, then snaps it back to 0 at + // the loop boundary where the flower is already expanded. withTiming(40, { duration: ORBIT_COLLAPSE_DURATION, easing: Easing.inOut(Easing.cubic), @@ -416,13 +465,16 @@ export function AnalysisOrbitLoader() { -1, ), ); - flowerScale.set( + foldProgress.set( withRepeat( withSequence( + // Collapse by moving every petal center to the exact loader center. withTiming(0, { duration: ORBIT_COLLAPSE_DURATION, easing: Easing.inOut(Easing.cubic), }), + // Expand back out without changing `flowerRotation`, so the flower + // opens in place instead of spinning open. withTiming(1, { duration: ORBIT_EXPAND_DURATION, easing: Easing.out(Easing.cubic), @@ -435,17 +487,13 @@ export function AnalysisOrbitLoader() { return () => { cancelAnimation(flowerRotation); - cancelAnimation(flowerScale); + cancelAnimation(foldProgress); }; - }, [flowerRotation, flowerScale, reduceMotion]); + }, [flowerRotation, foldProgress, reduceMotion]); const flowerStyle = useAnimatedStyle(() => { - const scaleProgress = flowerScale.get(); return { - transform: [ - { rotate: `${flowerRotation.get()}deg` }, - { scale: 0.62 + scaleProgress * 0.38 }, - ], + transform: [{ rotate: `${flowerRotation.get()}deg` }], }; }); @@ -458,25 +506,16 @@ export function AnalysisOrbitLoader() { }} > - - {ANALYSIS_ORBIT_PETALS.map((petal) => ( - - ))} - + {ANALYSIS_ORBIT_PETALS.map((petal) => ( + + ))} );