From 9c6afd765666dbb3f8c02e470bed8517f7f9ae77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6ssner?= Date: Sun, 14 Jun 2026 18:22:18 +0200 Subject: [PATCH 1/4] chore: add React Compiler healthcheck command --- package.json | 109 +-------------------------------------------------- 1 file changed, 1 insertion(+), 108 deletions(-) diff --git a/package.json b/package.json index e67b7458..a51adcf7 100644 --- a/package.json +++ b/package.json @@ -1,108 +1 @@ -{ - "name": "dayova-mvp", - "version": "1.0.2", - "main": "index.ts", - "scripts": { - "preinstall": "only-allow pnpm", - "start": "run-p expo:start convex:dev", - "android": "run-p expo:android convex:dev", - "ios": "run-p expo:ios convex:dev", - "convex:dev": "convex dev", - "expo:start": "expo start", - "expo:android": "expo run:android", - "expo:ios": "expo run:ios", - "lint": "run-s lint:biome lint:plugins", - "lint:biome": "biome lint .", - "lint:plugins": "eslint src convex index.ts", - "lint:fix": "run-s lint:biome:fix lint:plugins:fix format:tailwind", - "lint:biome:fix": "biome check . --write", - "lint:plugins:fix": "eslint src convex index.ts --fix", - "format": "run-s format:biome format:tailwind", - "format:biome": "biome format . --write", - "format:tailwind": "biome lint . --only=nursery/useSortedClasses --write", - "format:check": "run-s format:biome:check format:tailwind:check", - "format:biome:check": "biome format .", - "format:tailwind:check": "biome lint . --only=nursery/useSortedClasses", - "test": "vitest run", - "check": "run-s lint typecheck", - "typecheck": "tsc --noEmit", - "check:unused": "knip" - }, - "dependencies": { - "@ai-sdk/google-vertex": "^4.0.112", - "@clerk/expo": "^3.2.14", - "@expo/metro-runtime": "~56.0.13", - "@expo/ui": "~56.0.15", - "@gilhrpenner/convex-files-control": "^0.5.3", - "@gorhom/bottom-sheet": "^5.2.14", - "@hugeicons/core-free-icons": "^4.1.1", - "@hugeicons/react-native": "^1.0.13", - "@rn-primitives/portal": "^1.4.0", - "@rn-primitives/slot": "^1.4.0", - "@t3-oss/env-core": "^0.13.11", - "ai": "^6.0.168", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "convex": "^1.39.1", - "expo": "~56.0.8", - "expo-constants": "^56.0.16", - "expo-crypto": "^56.0.4", - "expo-dev-client": "~56.0.18", - "expo-document-picker": "~56.0.4", - "expo-file-system": "~56.0.7", - "expo-font": "~56.0.5", - "expo-image-picker": "~56.0.15", - "expo-linear-gradient": "~56.0.4", - "expo-linking": "^56.0.13", - "expo-notifications": "~56.0.15", - "expo-router": "^56.2.8", - "expo-secure-store": "~56.0.4", - "expo-splash-screen": "~56.0.10", - "expo-status-bar": "~56.0.4", - "expo-system-ui": "~56.0.5", - "expo-updates": "~56.0.17", - "nativewind": "^4.2.3", - "officeparser": "^6.1.0", - "react": "19.2.3", - "react-dom": "19.2.3", - "react-native": "0.85.3", - "react-native-confirmation-code-field": "^9.0.0", - "react-native-css-interop": "^0.2.3", - "react-native-gesture-handler": "^2.31.2", - "react-native-keyboard-controller": "1.21.6", - "react-native-reanimated": "^4.3.1", - "react-native-safe-area-context": "^5.7.0", - "react-native-screens": "^4.25.2", - "react-native-svg": "^15.15.4", - "react-native-worklets": "0.8.3", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^3.4.1", - "tailwindcss-animate": "^1.0.7", - "zod": "^4.3.6" - }, - "devDependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx": "^7.28.6", - "@biomejs/biome": "^2.4.14", - "@convex-dev/eslint-plugin": "^2.0.0", - "@edge-runtime/vm": "^5.0.0", - "@types/node": "^25.6.0", - "@types/react": "~19.2.14", - "babel-preset-expo": "~56.0.10", - "convex-test": "^0.0.53", - "eslint": "^10.3.0", - "eslint-plugin-react-hooks": "^7.1.1", - "knip": "^6.13.1", - "npm-run-all2": "^8.0.4", - "only-allow": "^1.2.2", - "typescript": "~6.0.3", - "typescript-eslint": "^8.59.1", - "vite": "^8.0.14", - "vitest": "^4.1.7" - }, - "private": true, - "engines": { - "node": ">=22 <24" - }, - "packageManager": "pnpm@10.25.0" -} +{"name":"dayova-mvp","version":"1.0.2","main":"index.ts","scripts":{"preinstall":"only-allow pnpm","start":"run-p expo:start convex:dev","android":"run-p expo:android convex:dev","ios":"run-p expo:ios convex:dev","convex:dev":"convex dev","expo:start":"expo start","expo:android":"expo run:android","expo:ios":"expo run:ios","lint":"run-s lint:biome lint:plugins","lint:biome":"biome lint .","lint:plugins":"eslint src convex index.ts","lint:fix":"run-s lint:biome:fix lint:plugins:fix format:tailwind","lint:biome:fix":"biome check . --write","lint:plugins:fix":"eslint src convex index.ts --fix","format":"run-s format:biome format:tailwind","format:biome":"biome format . --write","format:tailwind":"biome lint . --only=nursery/useSortedClasses --write","format:check":"run-s format:biome:check format:tailwind:check","format:biome:check":"biome format .","format:tailwind:check":"biome lint . --only=nursery/useSortedClasses","test":"vitest run","check":"run-s lint typecheck","check:compiler":"pnpm dlx react-compiler-healthcheck@latest","typecheck":"tsc --noEmit","check:unused":"knip"},"dependencies":{"@ai-sdk/google-vertex":"^4.0.112","@clerk/expo":"^3.2.14","@expo/metro-runtime":"~56.0.13","@expo/ui":"~56.0.15","@gilhrpenner/convex-files-control":"^0.5.3","@gorhom/bottom-sheet":"^5.2.14","@hugeicons/core-free-icons":"^4.1.1","@hugeicons/react-native":"^1.0.13","@rn-primitives/portal":"^1.4.0","@rn-primitives/slot":"^1.4.0","@t3-oss/env-core":"^0.13.11","ai":"^6.0.168","class-variance-authority":"^0.7.1","clsx":"^2.1.1","convex":"^1.39.1","expo":"~56.0.8","expo-constants":"^56.0.16","expo-crypto":"^56.0.4","expo-dev-client":"~56.0.18","expo-document-picker":"~56.0.4","expo-file-system":"~56.0.7","expo-font":"~56.0.5","expo-image-picker":"~56.0.15","expo-linear-gradient":"~56.0.4","expo-linking":"^56.0.13","expo-notifications":"~56.0.15","expo-router":"^56.2.8","expo-secure-store":"~56.0.4","expo-splash-screen":"~56.0.10","expo-status-bar":"~56.0.4","expo-system-ui":"~56.0.5","expo-updates":"~56.0.17","nativewind":"^4.2.3","officeparser":"^6.1.0","react":"19.2.3","react-dom":"19.2.3","react-native":"0.85.3","react-native-confirmation-code-field":"^9.0.0","react-native-css-interop":"^0.2.3","react-native-gesture-handler":"^2.31.2","react-native-keyboard-controller":"1.21.6","react-native-reanimated":"^4.3.1","react-native-safe-area-context":"^5.7.0","react-native-screens":"^4.25.2","react-native-svg":"^15.15.4","react-native-worklets":"0.8.3","tailwind-merge":"^3.5.0","tailwindcss":"^3.4.1","tailwindcss-animate":"^1.0.7","zod":"^4.3.6"},"devDependencies":{"@babel/core":"^7.29.0","@babel/plugin-transform-react-jsx":"^7.28.6","@biomejs/biome":"^2.4.14","@convex-dev/eslint-plugin":"^2.0.0","@edge-runtime/vm":"^5.0.0","@types/node":"^25.6.0","@types/react":"~19.2.14","babel-preset-expo":"~56.0.10","convex-test":"^0.0.53","eslint":"^10.3.0","eslint-plugin-react-hooks":"^7.1.1","knip":"^6.13.1","npm-run-all2":"^8.0.4","only-allow":"^1.2.2","typescript":"~6.0.3","typescript-eslint":"^8.59.1","vite":"^8.0.14","vitest":"^4.1.7"},"private":true,"engines":{"node":">=22 <24"},"packageManager":"pnpm@10.25.0"} \ No newline at end of file From 578ff3ab23d5900d67e68f8d92ed52c3ce9dead9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6ssner?= Date: Sun, 14 Jun 2026 18:23:05 +0200 Subject: [PATCH 2/4] feat: respect reduced motion in quiz transitions --- src/features/learning-plans/quiz-step.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/features/learning-plans/quiz-step.tsx b/src/features/learning-plans/quiz-step.tsx index f3fd1fd6..69108cbb 100644 --- a/src/features/learning-plans/quiz-step.tsx +++ b/src/features/learning-plans/quiz-step.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { ActivityIndicator, Animated, Easing, View } from "react-native"; +import { useReducedMotion } from "react-native-reanimated"; import Svg, { Circle, Path } from "react-native-svg"; import { Button } from "~/components/ui/button"; import { FieldControl, FieldLabel } from "~/components/ui/field"; @@ -59,19 +60,29 @@ export function QuizStep({ const trimmedAnswer = answer.trim(); const questionId = question.id; const prompt = formatGermanUiText(question.prompt); + const reduceMotion = useReducedMotion(); const [transition] = useState(() => new Animated.Value(1)); useEffect(() => { if (!questionId) return; + transition.stopAnimation(); + if (reduceMotion) { + transition.setValue(1); + return; + } + transition.setValue(0); - Animated.timing(transition, { + const animation = Animated.timing(transition, { toValue: 1, duration: 280, easing: Easing.out(Easing.cubic), useNativeDriver: true, - }).start(); - }, [questionId, transition]); + }); + animation.start(); + + return () => animation.stop(); + }, [questionId, reduceMotion, transition]); const contentTranslateY = transition.interpolate({ inputRange: [0, 1], From a71ff44a82211c2bb141fc1d6281cd49f722752e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6ssner?= Date: Sun, 14 Jun 2026 18:23:42 +0200 Subject: [PATCH 3/4] style: format package manifest --- package.json | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a51adcf7..91085a43 100644 --- a/package.json +++ b/package.json @@ -1 +1,109 @@ -{"name":"dayova-mvp","version":"1.0.2","main":"index.ts","scripts":{"preinstall":"only-allow pnpm","start":"run-p expo:start convex:dev","android":"run-p expo:android convex:dev","ios":"run-p expo:ios convex:dev","convex:dev":"convex dev","expo:start":"expo start","expo:android":"expo run:android","expo:ios":"expo run:ios","lint":"run-s lint:biome lint:plugins","lint:biome":"biome lint .","lint:plugins":"eslint src convex index.ts","lint:fix":"run-s lint:biome:fix lint:plugins:fix format:tailwind","lint:biome:fix":"biome check . --write","lint:plugins:fix":"eslint src convex index.ts --fix","format":"run-s format:biome format:tailwind","format:biome":"biome format . --write","format:tailwind":"biome lint . --only=nursery/useSortedClasses --write","format:check":"run-s format:biome:check format:tailwind:check","format:biome:check":"biome format .","format:tailwind:check":"biome lint . --only=nursery/useSortedClasses","test":"vitest run","check":"run-s lint typecheck","check:compiler":"pnpm dlx react-compiler-healthcheck@latest","typecheck":"tsc --noEmit","check:unused":"knip"},"dependencies":{"@ai-sdk/google-vertex":"^4.0.112","@clerk/expo":"^3.2.14","@expo/metro-runtime":"~56.0.13","@expo/ui":"~56.0.15","@gilhrpenner/convex-files-control":"^0.5.3","@gorhom/bottom-sheet":"^5.2.14","@hugeicons/core-free-icons":"^4.1.1","@hugeicons/react-native":"^1.0.13","@rn-primitives/portal":"^1.4.0","@rn-primitives/slot":"^1.4.0","@t3-oss/env-core":"^0.13.11","ai":"^6.0.168","class-variance-authority":"^0.7.1","clsx":"^2.1.1","convex":"^1.39.1","expo":"~56.0.8","expo-constants":"^56.0.16","expo-crypto":"^56.0.4","expo-dev-client":"~56.0.18","expo-document-picker":"~56.0.4","expo-file-system":"~56.0.7","expo-font":"~56.0.5","expo-image-picker":"~56.0.15","expo-linear-gradient":"~56.0.4","expo-linking":"^56.0.13","expo-notifications":"~56.0.15","expo-router":"^56.2.8","expo-secure-store":"~56.0.4","expo-splash-screen":"~56.0.10","expo-status-bar":"~56.0.4","expo-system-ui":"~56.0.5","expo-updates":"~56.0.17","nativewind":"^4.2.3","officeparser":"^6.1.0","react":"19.2.3","react-dom":"19.2.3","react-native":"0.85.3","react-native-confirmation-code-field":"^9.0.0","react-native-css-interop":"^0.2.3","react-native-gesture-handler":"^2.31.2","react-native-keyboard-controller":"1.21.6","react-native-reanimated":"^4.3.1","react-native-safe-area-context":"^5.7.0","react-native-screens":"^4.25.2","react-native-svg":"^15.15.4","react-native-worklets":"0.8.3","tailwind-merge":"^3.5.0","tailwindcss":"^3.4.1","tailwindcss-animate":"^1.0.7","zod":"^4.3.6"},"devDependencies":{"@babel/core":"^7.29.0","@babel/plugin-transform-react-jsx":"^7.28.6","@biomejs/biome":"^2.4.14","@convex-dev/eslint-plugin":"^2.0.0","@edge-runtime/vm":"^5.0.0","@types/node":"^25.6.0","@types/react":"~19.2.14","babel-preset-expo":"~56.0.10","convex-test":"^0.0.53","eslint":"^10.3.0","eslint-plugin-react-hooks":"^7.1.1","knip":"^6.13.1","npm-run-all2":"^8.0.4","only-allow":"^1.2.2","typescript":"~6.0.3","typescript-eslint":"^8.59.1","vite":"^8.0.14","vitest":"^4.1.7"},"private":true,"engines":{"node":">=22 <24"},"packageManager":"pnpm@10.25.0"} \ No newline at end of file +{ + "name": "dayova-mvp", + "version": "1.0.2", + "main": "index.ts", + "scripts": { + "preinstall": "only-allow pnpm", + "start": "run-p expo:start convex:dev", + "android": "run-p expo:android convex:dev", + "ios": "run-p expo:ios convex:dev", + "convex:dev": "convex dev", + "expo:start": "expo start", + "expo:android": "expo run:android", + "expo:ios": "expo run:ios", + "lint": "run-s lint:biome lint:plugins", + "lint:biome": "biome lint .", + "lint:plugins": "eslint src convex index.ts", + "lint:fix": "run-s lint:biome:fix lint:plugins:fix format:tailwind", + "lint:biome:fix": "biome check . --write", + "lint:plugins:fix": "eslint src convex index.ts --fix", + "format": "run-s format:biome format:tailwind", + "format:biome": "biome format . --write", + "format:tailwind": "biome lint . --only=nursery/useSortedClasses --write", + "format:check": "run-s format:biome:check format:tailwind:check", + "format:biome:check": "biome format .", + "format:tailwind:check": "biome lint . --only=nursery/useSortedClasses", + "test": "vitest run", + "check": "run-s lint typecheck", + "check:compiler": "pnpm dlx react-compiler-healthcheck@latest", + "typecheck": "tsc --noEmit", + "check:unused": "knip" + }, + "dependencies": { + "@ai-sdk/google-vertex": "^4.0.112", + "@clerk/expo": "^3.2.14", + "@expo/metro-runtime": "~56.0.13", + "@expo/ui": "~56.0.15", + "@gilhrpenner/convex-files-control": "^0.5.3", + "@gorhom/bottom-sheet": "^5.2.14", + "@hugeicons/core-free-icons": "^4.1.1", + "@hugeicons/react-native": "^1.0.13", + "@rn-primitives/portal": "^1.4.0", + "@rn-primitives/slot": "^1.4.0", + "@t3-oss/env-core": "^0.13.11", + "ai": "^6.0.168", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "convex": "^1.39.1", + "expo": "~56.0.8", + "expo-constants": "^56.0.16", + "expo-crypto": "^56.0.4", + "expo-dev-client": "~56.0.18", + "expo-document-picker": "~56.0.4", + "expo-file-system": "~56.0.7", + "expo-font": "~56.0.5", + "expo-image-picker": "~56.0.15", + "expo-linear-gradient": "~56.0.4", + "expo-linking": "^56.0.13", + "expo-notifications": "~56.0.15", + "expo-router": "^56.2.8", + "expo-secure-store": "~56.0.4", + "expo-splash-screen": "~56.0.10", + "expo-status-bar": "~56.0.4", + "expo-system-ui": "~56.0.5", + "expo-updates": "~56.0.17", + "nativewind": "^4.2.3", + "officeparser": "^6.1.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-confirmation-code-field": "^9.0.0", + "react-native-css-interop": "^0.2.3", + "react-native-gesture-handler": "^2.31.2", + "react-native-keyboard-controller": "1.21.6", + "react-native-reanimated": "^4.3.1", + "react-native-safe-area-context": "^5.7.0", + "react-native-screens": "^4.25.2", + "react-native-svg": "^15.15.4", + "react-native-worklets": "0.8.3", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^3.4.1", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.3.6" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@biomejs/biome": "^2.4.14", + "@convex-dev/eslint-plugin": "^2.0.0", + "@edge-runtime/vm": "^5.0.0", + "@types/node": "^25.6.0", + "@types/react": "~19.2.14", + "babel-preset-expo": "~56.0.10", + "convex-test": "^0.0.53", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "knip": "^6.13.1", + "npm-run-all2": "^8.0.4", + "only-allow": "^1.2.2", + "typescript": "~6.0.3", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.14", + "vitest": "^4.1.7" + }, + "private": true, + "engines": { + "node": ">=22 <24" + }, + "packageManager": "pnpm@10.25.0" +} From 8e14c90ba3de6d65e050b849c0c6925986b2cb96 Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Mon, 15 Jun 2026 19:40:13 +0200 Subject: [PATCH 4/4] perf: optimize animation setup --- src/app/(app)/home.tsx | 32 ++-- src/app/notifications.tsx | 79 +++++---- src/components/Mascot.tsx | 10 +- src/components/bottom-nav.tsx | 43 +++-- .../learning-plans/learning-plan-ui.tsx | 154 ++++++++++-------- 5 files changed, 177 insertions(+), 141 deletions(-) diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx index 9ed22d63..87a21924 100644 --- a/src/app/(app)/home.tsx +++ b/src/app/(app)/home.tsx @@ -165,12 +165,12 @@ function DragStartSlider({ .failOffsetY([-14 * scale, 14 * scale]) .onBegin(() => { "worklet"; - dragX.value = 0; - isCompleting.value = false; + dragX.set(0); + isCompleting.set(false); }) .onUpdate((event) => { "worklet"; - dragX.value = Math.min(Math.max(event.translationX, 0), maxDrag); + dragX.set(Math.min(Math.max(event.translationX, 0), maxDrag)); }) .onEnd((event) => { "worklet"; @@ -179,25 +179,27 @@ function DragStartSlider({ nextValue >= maxDrag * 0.72 || (event.velocityX > 650 && nextValue >= maxDrag * 0.35); - if (!shouldComplete || isCompleting.value) return; + if (!shouldComplete || isCompleting.get()) return; - isCompleting.value = true; - dragX.value = withTiming(maxDrag, { duration: 90 }, (finished) => { - "worklet"; - if (!finished) return; - scheduleOnRN(onComplete); - dragX.value = withTiming(0, { duration: 120 }); - isCompleting.value = false; - }); + isCompleting.set(true); + dragX.set( + withTiming(maxDrag, { duration: 90 }, (finished) => { + "worklet"; + if (!finished) return; + scheduleOnRN(onComplete); + dragX.set(withTiming(0, { duration: 120 })); + isCompleting.set(false); + }), + ); }) .onFinalize(() => { "worklet"; - if (isCompleting.value) return; - dragX.value = withTiming(0, { duration: 160 }); + if (isCompleting.get()) return; + dragX.set(withTiming(0, { duration: 160 })); }); const knobAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: dragX.value }], + transform: [{ translateX: dragX.get() }], })); return ( diff --git a/src/app/notifications.tsx b/src/app/notifications.tsx index 90ee0b42..445519b4 100644 --- a/src/app/notifications.tsx +++ b/src/app/notifications.tsx @@ -65,6 +65,10 @@ const SWIPE_DELETE_MIN_DISTANCE = 96; const SWIPE_DELETE_MAX_DISTANCE = 132; const SWIPE_DELETE_FLING_DISTANCE = 40; const SWIPE_DELETE_FLING_VELOCITY = -700; +const NOTIFICATION_EXIT = FadeOut.duration(90); +const NOTIFICATION_LAYOUT = LinearTransition.springify() + .damping(20) + .stiffness(180); const getNotificationsModule = () => { try { @@ -171,8 +175,8 @@ function CategoryTabs({ const indicatorWidth = useSharedValue(0); const selectionPosition = useSharedValue(selectedIndex); const indicatorStyle = useAnimatedStyle(() => ({ - width: indicatorWidth.value, - transform: [{ translateX: selectionPosition.value * indicatorWidth.value }], + width: indicatorWidth.get(), + transform: [{ translateX: selectionPosition.get() * indicatorWidth.get() }], })); useEffect(() => { @@ -262,7 +266,7 @@ function CategoryTabLabel({ }) { const animatedStyle = useAnimatedStyle(() => ({ color: interpolateColor( - Math.min(Math.abs(selectionPosition.value - index), 1), + Math.min(Math.abs(selectionPosition.get() - index), 1), [0, 1], ["#FFFFFF", "#1A1A1A"], ), @@ -306,20 +310,19 @@ function NotificationCard({ .failOffsetY([-12, 12]) .onUpdate((event) => { "worklet"; - if (isDeleting.value) return; + if (isDeleting.get()) return; - translateX.value = Math.max( - Math.min(event.translationX, 0), - -cardWidth.value, + translateX.set( + Math.max(Math.min(event.translationX, 0), -cardWidth.get()), ); }) .onEnd((event) => { "worklet"; - if (isDeleting.value) return; + if (isDeleting.get()) return; - const distance = -translateX.value; + const distance = -translateX.get(); const deleteThreshold = Math.min( - Math.max(cardWidth.value * 0.3, SWIPE_DELETE_MIN_DISTANCE), + Math.max(cardWidth.get() * 0.3, SWIPE_DELETE_MIN_DISTANCE), SWIPE_DELETE_MAX_DISTANCE, ); const shouldDelete = @@ -329,40 +332,44 @@ function NotificationCard({ if (!shouldDelete) return; - isDeleting.value = true; - translateX.value = withTiming( - -cardWidth.value - 24, - { - duration: 180, - easing: Easing.out(Easing.cubic), - }, - (finished) => { - "worklet"; - if (finished) scheduleOnRN(commitDelete); - }, + isDeleting.set(true); + translateX.set( + withTiming( + -cardWidth.get() - 24, + { + duration: 180, + easing: Easing.out(Easing.cubic), + }, + (finished) => { + "worklet"; + if (finished) scheduleOnRN(commitDelete); + }, + ), ); }) .onFinalize(() => { "worklet"; - if (isDeleting.value) return; - - translateX.value = withSpring(0, { - damping: 20, - mass: 0.7, - overshootClamping: true, - stiffness: 260, - }); + if (isDeleting.get()) return; + + translateX.set( + withSpring(0, { + damping: 20, + mass: 0.7, + overshootClamping: true, + stiffness: 260, + }), + ); }); const cardAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], + transform: [{ translateX: translateX.get() }], })); const deleteBackgroundAnimatedStyle = useAnimatedStyle(() => { const deleteThreshold = Math.min( - Math.max(cardWidth.value * 0.3, SWIPE_DELETE_MIN_DISTANCE), + Math.max(cardWidth.get() * 0.3, SWIPE_DELETE_MIN_DISTANCE), SWIPE_DELETE_MAX_DISTANCE, ); - const progress = Math.min(-translateX.value / deleteThreshold, 1); + const progress = Math.min(-translateX.get() / deleteThreshold, 1); return { backgroundColor: interpolateColor( @@ -374,10 +381,10 @@ function NotificationCard({ }); const deleteIconAnimatedStyle = useAnimatedStyle(() => { const deleteThreshold = Math.min( - Math.max(cardWidth.value * 0.3, SWIPE_DELETE_MIN_DISTANCE), + Math.max(cardWidth.get() * 0.3, SWIPE_DELETE_MIN_DISTANCE), SWIPE_DELETE_MAX_DISTANCE, ); - const progress = Math.min(-translateX.value / deleteThreshold, 1); + const progress = Math.min(-translateX.get() / deleteThreshold, 1); return { opacity: interpolate(progress, [0, 0.18, 1], [0, 1, 1], "clamp"), @@ -402,8 +409,8 @@ function NotificationCard({ return ( = ({ size = 120, pose = "default" }) => { const [floatAnim] = useState(() => new Animated.Value(0)); const [moodAnim] = useState(() => new Animated.Value(0)); + const reduceMotion = useReducedMotion(); useEffect(() => { + floatAnim.stopAnimation(); + moodAnim.stopAnimation(); + floatAnim.setValue(0); + moodAnim.setValue(0); + if (reduceMotion) return; + const floatLoop = Animated.loop( Animated.sequence([ Animated.timing(floatAnim, { @@ -60,7 +68,7 @@ export const Mascot: FC = ({ size = 120, pose = "default" }) => { floatLoop.stop(); moodLoop.stop(); }; - }, [floatAnim, moodAnim, pose]); + }, [floatAnim, moodAnim, pose, reduceMotion]); const translateY = floatAnim.interpolate({ inputRange: [0, 1], diff --git a/src/components/bottom-nav.tsx b/src/components/bottom-nav.tsx index 965db18e..1e661a95 100644 --- a/src/components/bottom-nav.tsx +++ b/src/components/bottom-nav.tsx @@ -72,20 +72,25 @@ function AnimatedTabIcon({ const focusProgress = useSharedValue(active ? 1 : 0); useEffect(() => { - focusProgress.value = withSpring(active ? 1 : 0, { - damping: 14, - mass: 0.55, - stiffness: 260, - }); + focusProgress.set( + withSpring(active ? 1 : 0, { + damping: 14, + mass: 0.55, + stiffness: 260, + }), + ); }, [active, focusProgress]); - const animatedStyle = useAnimatedStyle(() => ({ - opacity: 0.76 + focusProgress.value * 0.24, - transform: [ - { translateY: focusProgress.value * -2 * scale }, - { scale: 1 + focusProgress.value * 0.1 }, - ], - })); + const animatedStyle = useAnimatedStyle(() => { + const progress = focusProgress.get(); + return { + opacity: 0.76 + progress * 0.24, + transform: [ + { translateY: progress * -2 * scale }, + { scale: 1 + progress * 0.1 }, + ], + }; + }); return ( @@ -110,17 +115,19 @@ export function BottomNav({ state, navigation }: BottomNavProps) { const indicatorPosition = useSharedValue(activeItemIndex); useEffect(() => { - indicatorPosition.value = withSpring(activeItemIndex, { - damping: 17, - mass: 0.72, - stiffness: 210, - }); + indicatorPosition.set( + withSpring(activeItemIndex, { + damping: 17, + mass: 0.72, + stiffness: 210, + }), + ); }, [activeItemIndex, indicatorPosition]); const indicatorStyle = useAnimatedStyle(() => ({ transform: [ { - translateX: indicatorPosition.value * (ITEM_SIZE + ITEM_GAP) * scale, + translateX: indicatorPosition.get() * (ITEM_SIZE + ITEM_GAP) * scale, }, ], })); diff --git a/src/features/learning-plans/learning-plan-ui.tsx b/src/features/learning-plans/learning-plan-ui.tsx index 9f9209ab..118fcfeb 100644 --- a/src/features/learning-plans/learning-plan-ui.tsx +++ b/src/features/learning-plans/learning-plan-ui.tsx @@ -1,14 +1,17 @@ -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import Animated, { + cancelAnimation, Easing, - interpolate, - type SharedValue, useAnimatedStyle, + useReducedMotion, useSharedValue, + withDelay, + withRepeat, withSequence, withTiming, } from "react-native-reanimated"; +import Svg, { Circle } from "react-native-svg"; import { Button } from "~/components/ui/button"; import { FieldAccessory, @@ -57,17 +60,23 @@ const getSessionPhaseLabel = (phase: SessionPhase) => const sessionPhaseOptions: SessionPhase[] = ["theory", "practice", "rehearsal"]; -const ANALYSIS_ORBITS = Array.from({ length: 9 }, (_, index) => ({ - id: `analysis-orbit-${index}`, - rotation: index * 40, -})); 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_ROTATION_STEP = 48; +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, + }; +}); export function SectionTitle({ title, @@ -345,71 +354,65 @@ export function SessionEditForm({ ); } -function AnalysisOrbitPetal({ - rotation, - expansion, -}: { - rotation: number; - expansion: SharedValue; -}) { - const petalStyle = useAnimatedStyle(() => ({ - transform: [ - { rotate: `${rotation}deg` }, - { translateY: -ANALYSIS_ORBIT_PETAL_DISTANCE * expansion.value }, - { scale: interpolate(expansion.value, [0, 1], [0.92, 1]) }, - ], - })); - - return ( - - ); -} - export function AnalysisOrbitLoader() { - const expansion = useSharedValue(1); + const flowerScale = useSharedValue(1); const flowerRotation = useSharedValue(0); - const rotationTarget = useRef(0); + const reduceMotion = useReducedMotion(); useEffect(() => { - const runCycle = () => { - rotationTarget.current += ORBIT_ROTATION_STEP; - flowerRotation.value = withTiming(rotationTarget.current, { - duration: ORBIT_COLLAPSE_DURATION, - easing: Easing.inOut(Easing.cubic), - }); - expansion.value = withSequence( - withTiming(0, { - duration: ORBIT_COLLAPSE_DURATION, - easing: Easing.inOut(Easing.cubic), - }), - withTiming(1, { - duration: ORBIT_EXPAND_DURATION, - easing: Easing.out(Easing.cubic), - }), - ); - }; + if (reduceMotion) { + flowerRotation.set(0); + flowerScale.set(1); + return; + } - const initialCycle = setTimeout(runCycle, 180); - const cycle = setInterval(runCycle, ORBIT_CYCLE_DURATION); + flowerRotation.set( + withRepeat( + withSequence( + withTiming(40, { + duration: ORBIT_COLLAPSE_DURATION, + easing: Easing.inOut(Easing.cubic), + }), + withDelay( + ORBIT_EXPAND_DURATION + ORBIT_REST_DURATION, + withTiming(0, { duration: 0 }), + ), + ), + -1, + ), + ); + flowerScale.set( + withRepeat( + withSequence( + withTiming(0, { + duration: ORBIT_COLLAPSE_DURATION, + easing: Easing.inOut(Easing.cubic), + }), + withTiming(1, { + duration: ORBIT_EXPAND_DURATION, + easing: Easing.out(Easing.cubic), + }), + withDelay(ORBIT_REST_DURATION, withTiming(1, { duration: 0 })), + ), + -1, + ), + ); return () => { - clearTimeout(initialCycle); - clearInterval(cycle); + cancelAnimation(flowerRotation); + cancelAnimation(flowerScale); }; - }, [expansion, flowerRotation]); + }, [flowerRotation, flowerScale, reduceMotion]); - const flowerStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${flowerRotation.value}deg` }], - })); + const flowerStyle = useAnimatedStyle(() => { + const scaleProgress = flowerScale.get(); + return { + transform: [ + { rotate: `${flowerRotation.get()}deg` }, + { scale: 0.62 + scaleProgress * 0.38 }, + ], + }; + }); return ( - {ANALYSIS_ORBITS.map((orbit) => ( - - ))} + + {ANALYSIS_ORBIT_PETALS.map((petal) => ( + + ))} + );