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
44 changes: 44 additions & 0 deletions src/features/learning-plans/analysis-orbit-loader.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
});
});
51 changes: 51 additions & 0 deletions src/features/learning-plans/analysis-orbit-loader.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
119 changes: 79 additions & 40 deletions src/features/learning-plans/learning-plan-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
type SharedValue,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -389,21 +391,68 @@ export function SessionEditForm({
);
}

function AnalysisOrbitPetalCircle({
petal,
foldProgress,
}: {
petal: AnalysisOrbitPetal;
foldProgress: SharedValue<number>;
}) {
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 (
<Animated.View
style={[
{
// Each petal is drawn once as a native rounded view centered in the
// loader. Animation only changes transform, which is cheaper than
// changing layout, SVG attributes, or React state on every frame.
position: "absolute",
left: ANALYSIS_ORBIT_CENTER - ANALYSIS_ORBIT_PETAL_SIZE / 2,
top: ANALYSIS_ORBIT_CENTER - ANALYSIS_ORBIT_PETAL_SIZE / 2,
height: ANALYSIS_ORBIT_PETAL_SIZE,
width: ANALYSIS_ORBIT_PETAL_SIZE,
borderRadius: ANALYSIS_ORBIT_PETAL_SIZE / 2,
backgroundColor: DAYOVA_DESIGN_SYSTEM.colors.primary,
opacity: ORBIT_PETAL_OPACITY,
},
petalStyle,
]}
/>
);
}

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),
Expand All @@ -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),
Expand All @@ -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` }],
};
});

Expand All @@ -458,25 +506,16 @@ export function AnalysisOrbitLoader() {
}}
>
<Animated.View
className="h-full w-full items-center justify-center"
className="h-full w-full"
style={flowerStyle}
>
<Svg
width={ANALYSIS_ORBIT_LOADER_SIZE}
height={ANALYSIS_ORBIT_LOADER_SIZE}
viewBox={`0 0 ${ANALYSIS_ORBIT_LOADER_SIZE} ${ANALYSIS_ORBIT_LOADER_SIZE}`}
>
{ANALYSIS_ORBIT_PETALS.map((petal) => (
<Circle
key={petal.id}
cx={petal.cx}
cy={petal.cy}
r={ANALYSIS_ORBIT_PETAL_SIZE / 2}
fill="#3A7BFF"
fillOpacity={0.55}
/>
))}
</Svg>
{ANALYSIS_ORBIT_PETALS.map((petal) => (
<AnalysisOrbitPetalCircle
key={petal.id}
petal={petal}
foldProgress={foldProgress}
/>
))}
</Animated.View>
</View>
);
Expand Down