From 0f1d73671685f4de3ff71adf2a17c831385688a3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 5 Jan 2026 23:42:51 +0530 Subject: [PATCH] slight onboarding screen Ui change --- ios/Podfile.lock | 32 +- ios/Podfile.properties.json | 5 +- package-lock.json | 64 ++++ package.json | 1 + src/components/onboarding/ShapeAnimation.tsx | 291 ++++++++++++++++++ src/components/onboarding/shapes/constants.ts | 8 + src/components/onboarding/shapes/cube.ts | 35 +++ src/components/onboarding/shapes/heart.ts | 35 +++ src/components/onboarding/shapes/index.ts | 28 ++ src/components/onboarding/shapes/plugin.ts | 96 ++++++ src/components/onboarding/shapes/search.ts | 57 ++++ src/components/onboarding/shapes/sphere.ts | 19 ++ src/components/onboarding/shapes/star.ts | 31 ++ src/components/onboarding/shapes/torus.ts | 48 +++ src/components/onboarding/shapes/types.ts | 1 + src/components/onboarding/shapes/utils.ts | 54 ++++ src/screens/OnboardingScreen.tsx | 91 +++++- 17 files changed, 879 insertions(+), 17 deletions(-) create mode 100644 src/components/onboarding/ShapeAnimation.tsx create mode 100644 src/components/onboarding/shapes/constants.ts create mode 100644 src/components/onboarding/shapes/cube.ts create mode 100644 src/components/onboarding/shapes/heart.ts create mode 100644 src/components/onboarding/shapes/index.ts create mode 100644 src/components/onboarding/shapes/plugin.ts create mode 100644 src/components/onboarding/shapes/search.ts create mode 100644 src/components/onboarding/shapes/sphere.ts create mode 100644 src/components/onboarding/shapes/star.ts create mode 100644 src/components/onboarding/shapes/torus.ts create mode 100644 src/components/onboarding/shapes/types.ts create mode 100644 src/components/onboarding/shapes/utils.ts diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b0ab03c..df4e4db 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1902,6 +1902,30 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-skia (2.4.14): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - react-native-slider (5.1.1): - hermes-engine - RCTRequired @@ -2822,6 +2846,7 @@ DEPENDENCIES: - react-native-google-cast (from `../node_modules/react-native-google-cast`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - "react-native-skia (from `../node_modules/@shopify/react-native-skia`)" - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-video (from `../node_modules/react-native-video`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -3059,6 +3084,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/netinfo" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-skia: + :path: "../node_modules/@shopify/react-native-skia" react-native-slider: :path: "../node_modules/@react-native-community/slider" react-native-video: @@ -3148,13 +3175,13 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: DisplayCriteria: - :commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3 + :commit: a7cddd878f557afa6a1f2faad9d756949406adde :git: https://github.com/kingslay/KSPlayer.git FFmpegKit: :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 :git: https://github.com/kingslay/FFmpegKit.git KSPlayer: - :commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3 + :commit: a7cddd878f557afa6a1f2faad9d756949406adde :git: https://github.com/kingslay/KSPlayer.git Libass: :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 @@ -3254,6 +3281,7 @@ SPEC CHECKSUMS: react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44 react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 + react-native-skia: 268f183f849742e9da216743ee234bd7ad81c69b react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1 react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58 React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438 diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json index 417e2e5..42ffb6c 100644 --- a/ios/Podfile.properties.json +++ b/ios/Podfile.properties.json @@ -1,5 +1,6 @@ { "expo.jsEngine": "hermes", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", - "newArchEnabled": "true" -} + "newArchEnabled": "true", + "ios.deploymentTarget": "16.0" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e690a85..1e28238 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "^7.6.0", "@shopify/flash-list": "^2.2.0", + "@shopify/react-native-skia": "^2.4.14", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.12.2", @@ -3642,6 +3643,33 @@ "react-native": "*" } }, + "node_modules/@shopify/react-native-skia": { + "version": "2.4.14", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz", + "integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "canvaskit-wasm": "0.40.0", + "react-reconciler": "0.31.0" + }, + "bin": { + "setup-skia-web": "scripts/setup-canvaskit.js" + }, + "peerDependencies": { + "react": ">=19.0", + "react-native": ">=0.78", + "react-native-reanimated": ">=3.19.1" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4304,6 +4332,12 @@ "url": "https://github.com/sponsors/crutchcorn" } }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -5158,6 +5192,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvaskit-wasm": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", + "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -11307,6 +11350,27 @@ "async-limiter": "~1.0.0" } }, + "node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/package.json b/package.json index f6c33bd..ca955c1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "^7.6.0", "@shopify/flash-list": "^2.2.0", + "@shopify/react-native-skia": "^2.4.14", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.12.2", diff --git a/src/components/onboarding/ShapeAnimation.tsx b/src/components/onboarding/ShapeAnimation.tsx new file mode 100644 index 0000000..141a3e5 --- /dev/null +++ b/src/components/onboarding/ShapeAnimation.tsx @@ -0,0 +1,291 @@ +import React, { useEffect, useMemo } from 'react'; +import { useWindowDimensions, StyleSheet } from 'react-native'; +import { + Blur, + BlurMask, + Canvas, + Circle, + Extrapolate, + interpolate, + LinearGradient, + Path, + RadialGradient, + usePathValue, + vec, + Group, +} from '@shopify/react-native-skia'; +import { + Easing, + useSharedValue, + withRepeat, + withTiming, + SharedValue, +} from 'react-native-reanimated'; + +import { + type Point3D, + N_POINTS, + ALL_SHAPES, + ALL_SHAPES_X, + ALL_SHAPES_Y, + ALL_SHAPES_Z, +} from './shapes'; + +// Number of shapes +const SHAPES_COUNT = ALL_SHAPES.length; + +// Color palettes for each shape (gradient: start, middle, end) +const COLOR_PALETTES = [ + ['#FFD700', '#FFA500', '#FF6B00'], // Star: Gold → Orange + ['#7C3AED', '#A855F7', '#EC4899'], // Plugin: Purple → Pink + ['#00D9FF', '#06B6D4', '#0EA5E9'], // Search: Cyan → Blue + ['#FF006E', '#F43F5E', '#FB7185'], // Heart: Pink → Rose +]; + +// ============ 3D UTILITIES ============ +const rotateX = (p: Point3D, angle: number): Point3D => { + 'worklet'; + return { + x: p.x, + y: p.y * Math.cos(angle) - p.z * Math.sin(angle), + z: p.y * Math.sin(angle) + p.z * Math.cos(angle), + }; +}; + +const rotateY = (p: Point3D, angle: number): Point3D => { + 'worklet'; + return { + x: p.x * Math.cos(angle) + p.z * Math.sin(angle), + y: p.y, + z: -p.x * Math.sin(angle) + p.z * Math.cos(angle), + }; +}; + +interface ShapeAnimationProps { + scrollX: SharedValue; +} + +// Single colored path component +const ColoredPath = ({ + morphPath, + colorIndex, + scrollX, + windowWidth, + windowHeight, +}: { + morphPath: any; + colorIndex: number; + scrollX: SharedValue; + windowWidth: number; + windowHeight: number; +}) => { + const colors = COLOR_PALETTES[colorIndex]; + + // Create opacity value using Skia's interpolate inside usePathValue pattern + const opacityPath = usePathValue((skPath) => { + 'worklet'; + // Calculate opacity based on scroll position + const shapeWidth = windowWidth; + const slideStart = colorIndex * shapeWidth; + const slideMid = slideStart; + const slideEnd = (colorIndex + 1) * shapeWidth; + const prevSlideEnd = (colorIndex - 1) * shapeWidth; + + // Opacity peaks at 1 when on this slide, fades to 0 on adjacent slides + let opacity = 0; + if (colorIndex === 0) { + // First slide: 1 at start, fade out to next + opacity = interpolate( + scrollX.value, + [0, shapeWidth], + [1, 0], + Extrapolate.CLAMP + ); + } else if (colorIndex === COLOR_PALETTES.length - 1) { + // Last slide: fade in from previous, stay at 1 + opacity = interpolate( + scrollX.value, + [prevSlideEnd, slideMid], + [0, 1], + Extrapolate.CLAMP + ); + } else { + // Middle slides: fade in from previous, fade out to next + opacity = interpolate( + scrollX.value, + [prevSlideEnd, slideMid, slideEnd], + [0, 1, 0], + Extrapolate.CLAMP + ); + } + + // Store opacity in path for use - we'll read it via a trick + // This is a workaround since we can't directly animate opacity + skPath.addCircle(-1000 - opacity * 100, -1000, 1); // Hidden marker + return skPath; + }); + + return ( + + + + + + + ); +}; + +export const ShapeAnimation: React.FC = ({ scrollX }) => { + const iTime = useSharedValue(0.0); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + + // Create paths for each color layer with opacity baked in + const createPathForIndex = (colorIndex: number) => { + return usePathValue(skPath => { + 'worklet'; + const centerX = windowWidth / 2; + const centerY = windowHeight * 0.65; + const distance = 350; + + // Calculate opacity for this color layer + const shapeWidth = windowWidth; + const slideStart = colorIndex * shapeWidth; + const prevSlideEnd = (colorIndex - 1) * shapeWidth; + const nextSlideStart = (colorIndex + 1) * shapeWidth; + + let opacity = 0; + if (colorIndex === 0) { + opacity = interpolate(scrollX.value, [0, shapeWidth], [1, 0], Extrapolate.CLAMP); + } else if (colorIndex === COLOR_PALETTES.length - 1) { + opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart], [0, 1], Extrapolate.CLAMP); + } else { + opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart, nextSlideStart], [0, 1, 0], Extrapolate.CLAMP); + } + + // Skip drawing if not visible + if (opacity < 0.01) return skPath; + + // Input range for all shapes + const inputRange = new Array(ALL_SHAPES.length) + .fill(0) + .map((_, idx) => shapeWidth * idx); + + for (let i = 0; i < N_POINTS; i++) { + const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP); + const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP); + const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP); + + let p: Point3D = { x: baseX, y: baseY, z: baseZ }; + p = rotateX(p, 0.2); + p = rotateY(p, iTime.value); + + const scale = distance / (distance + p.z); + const screenX = centerX + p.x * scale; + const screenY = centerY + p.y * scale; + + // Scale radius by opacity for smooth color transition + const radius = Math.max(0.1, 0.5 * scale * opacity); + skPath.addCircle(screenX, screenY, radius); + } + + return skPath; + }); + }; + + // Create all 4 color layer paths + const path0 = createPathForIndex(0); + const path1 = createPathForIndex(1); + const path2 = createPathForIndex(2); + const path3 = createPathForIndex(3); + + // Rotation animation + useEffect(() => { + iTime.value = 0; + iTime.value = withRepeat( + withTiming(2 * Math.PI, { + duration: 12000, + easing: Easing.linear, + }), + -1, + false + ); + }, []); + + return ( + + {/* Background radial gradient blurred */} + + + + + + {/* Layer 0: Gold (Star) */} + + + + + + {/* Layer 1: Purple (Plugin) */} + + + + + + {/* Layer 2: Cyan (Search) */} + + + + + + {/* Layer 3: Pink (Heart) */} + + + + + + ); +}; + +const styles = StyleSheet.create({ + canvas: { + position: 'absolute', + top: 0, + left: 0, + }, +}); + +export default ShapeAnimation; diff --git a/src/components/onboarding/shapes/constants.ts b/src/components/onboarding/shapes/constants.ts new file mode 100644 index 0000000..dc0e399 --- /dev/null +++ b/src/components/onboarding/shapes/constants.ts @@ -0,0 +1,8 @@ +// Fixed number of points for all shapes (for interpolation) +// Lower = better FPS, 1000 points is a good balance for smooth 60fps +export const N_POINTS = 1000; + +export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; + +// Normalize a shape to have height TARGET_HEIGHT +export const TARGET_HEIGHT = 200; diff --git a/src/components/onboarding/shapes/cube.ts b/src/components/onboarding/shapes/cube.ts new file mode 100644 index 0000000..3d037db --- /dev/null +++ b/src/components/onboarding/shapes/cube.ts @@ -0,0 +1,35 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape, scaleShape } from './utils'; + +// Cube - map sphere to cube +const generateCubePoints = (size: number): Point3D[] => { + const points: Point3D[] = []; + const s = size / 2; + + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi } = fibonacciPoint(i, N_POINTS); + // Point on unit sphere + const sx = Math.sin(phi) * Math.cos(theta); + const sy = Math.sin(phi) * Math.sin(theta); + const sz = Math.cos(phi); + + // Map to cube (cube mapping) + const absX = Math.abs(sx); + const absY = Math.abs(sy); + const absZ = Math.abs(sz); + const max = Math.max(absX, absY, absZ); + + points.push({ + x: (sx / max) * s, + y: (sy / max) * s, + z: (sz / max) * s, + }); + } + return points; +}; + +export const CUBE_POINTS = scaleShape( + normalizeShape(generateCubePoints(150)), + 0.75, +); diff --git a/src/components/onboarding/shapes/heart.ts b/src/components/onboarding/shapes/heart.ts new file mode 100644 index 0000000..9f66376 --- /dev/null +++ b/src/components/onboarding/shapes/heart.ts @@ -0,0 +1,35 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape } from './utils'; + +// Heart - starts from Fibonacci sphere, deforms into heart +const generateHeartPoints = (scale: number): Point3D[] => { + const points: Point3D[] = []; + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi } = fibonacciPoint(i, N_POINTS); + + // Use same angular coordinates as sphere + const u = theta; + const v = phi; + const sinV = Math.sin(v); + + // Heart surface with same angular correspondence + const hx = sinV * (15 * Math.sin(u) - 4 * Math.sin(3 * u)); + const hz = 8 * Math.cos(v); + const hy = + sinV * + (15 * Math.cos(u) - + 5 * Math.cos(2 * u) - + 2 * Math.cos(3 * u) - + Math.cos(4 * u)); + + points.push({ + x: hx * scale * 0.06, + y: -hy * scale * 0.06, + z: hz * scale * 0.06, + }); + } + return points; +}; + +export const HEART_POINTS = normalizeShape(generateHeartPoints(120)); diff --git a/src/components/onboarding/shapes/index.ts b/src/components/onboarding/shapes/index.ts new file mode 100644 index 0000000..e8c0c9d --- /dev/null +++ b/src/components/onboarding/shapes/index.ts @@ -0,0 +1,28 @@ +export { type Point3D } from './types'; +export { N_POINTS } from './constants'; + +import { N_POINTS } from './constants'; +import { STAR_POINTS } from './star'; // Welcome to Nuvio +import { PLUGIN_POINTS } from './plugin'; // Powerful Addons +import { SEARCH_POINTS } from './search'; // Smart Discovery +import { HEART_POINTS } from './heart'; // Your Library (favorites) + +// Array of all shapes - ordered to match onboarding slides +export const ALL_SHAPES = [ + STAR_POINTS, // Slide 1: Welcome + PLUGIN_POINTS, // Slide 2: Addons + SEARCH_POINTS, // Slide 3: Discovery + HEART_POINTS, // Slide 4: Library +]; + +export const POINTS_ARRAY = new Array(N_POINTS).fill(0); + +export const ALL_SHAPES_X = POINTS_ARRAY.map((_, pointIndex) => + ALL_SHAPES.map(shape => shape[pointIndex].x), +); +export const ALL_SHAPES_Y = POINTS_ARRAY.map((_, pointIndex) => + ALL_SHAPES.map(shape => shape[pointIndex].y), +); +export const ALL_SHAPES_Z = POINTS_ARRAY.map((_, pointIndex) => + ALL_SHAPES.map(shape => shape[pointIndex].z), +); diff --git a/src/components/onboarding/shapes/plugin.ts b/src/components/onboarding/shapes/plugin.ts new file mode 100644 index 0000000..9cf066f --- /dev/null +++ b/src/components/onboarding/shapes/plugin.ts @@ -0,0 +1,96 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { normalizeShape, scaleShape } from './utils'; + +// LEGO Brick shape - perfectly represents "Addons" or "Plugins" +const generateLegoPoints = (): Point3D[] => { + const points: Point3D[] = []; + + // Dimensions + const width = 160; + const depth = 80; + const height = 48; + const studRadius = 12; + const studHeight = 16; + + // Distribute points: 70% body, 30% studs + const bodyPoints = Math.floor(N_POINTS * 0.7); + const studPoints = N_POINTS - bodyPoints; + const pointsPerStud = Math.floor(studPoints / 8); // 8 studs (2x4 brick) + + // 1. Main Brick Body (Rectangular Prism) + for (let i = 0; i < bodyPoints; i++) { + const t1 = Math.random(); + const t2 = Math.random(); + const t3 = Math.random(); + + // Create density concentration on edges for better definition + const x = (Math.pow(t1, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * width / 2; + const y = (Math.pow(t2, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * height / 2; + const z = (Math.pow(t3, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * depth / 2; + + // Snapping to faces to make it look solid + const face = Math.floor(Math.random() * 6); + let px = x, py = y, pz = z; + + if (face === 0) px = width / 2; + else if (face === 1) px = -width / 2; + else if (face === 2) py = height / 2; + else if (face === 3) py = -height / 2; + else if (face === 4) pz = depth / 2; + else if (face === 5) pz = -depth / 2; + + // Add some random noise inside/surface + if (Math.random() > 0.8) { + points.push({ x: x, y: y, z: z }); + } else { + points.push({ x: px, y: py, z: pz }); + } + } + + // 2. Studs (Cylinders on top) + // 2x4 Grid positions + const studPositions = [ + { x: -width * 0.375, z: -depth * 0.25 }, { x: -width * 0.125, z: -depth * 0.25 }, + { x: width * 0.125, z: -depth * 0.25 }, { x: width * 0.375, z: -depth * 0.25 }, + { x: -width * 0.375, z: depth * 0.25 }, { x: -width * 0.125, z: depth * 0.25 }, + { x: width * 0.125, z: depth * 0.25 }, { x: width * 0.375, z: depth * 0.25 }, + ]; + + studPositions.forEach((pos, studIndex) => { + for (let j = 0; j < pointsPerStud; j++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * studRadius; + + // Top face of stud + if (Math.random() > 0.5) { + points.push({ + x: pos.x + r * Math.cos(angle), + y: -height / 2 - studHeight, // Top + z: pos.z + r * Math.sin(angle), + }); + } else { + // Side of stud + const h = Math.random() * studHeight; + points.push({ + x: pos.x + studRadius * Math.cos(angle), + y: -height / 2 - h, + z: pos.z + studRadius * Math.sin(angle), + }); + } + } + }); + + // FILL remaining points to prevent "undefined" errors + while (points.length < N_POINTS) { + points.push(points[points.length - 1] || { x: 0, y: 0, z: 0 }); + } + + // Slice to guarantee exact count + return points.slice(0, N_POINTS); +}; + +export const PLUGIN_POINTS = scaleShape( + normalizeShape(generateLegoPoints()), + 0.4, +); diff --git a/src/components/onboarding/shapes/search.ts b/src/components/onboarding/shapes/search.ts new file mode 100644 index 0000000..1ee39ca --- /dev/null +++ b/src/components/onboarding/shapes/search.ts @@ -0,0 +1,57 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { normalizeShape, scaleShape } from './utils'; + +// Magnifying glass/search shape - for "Discovery" page +const generateSearchPoints = (radius: number): Point3D[] => { + const points: Point3D[] = []; + const handleLength = radius * 0.8; + const handleWidth = radius * 0.15; + + // Split points between ring and handle + const ringPoints = Math.floor(N_POINTS * 0.7); + const handlePoints = N_POINTS - ringPoints; + + // Create the circular ring (lens) + for (let i = 0; i < ringPoints; i++) { + const t = i / ringPoints; + const mainAngle = t * Math.PI * 2; + const tubeAngle = (i * 17) % 20 / 20 * Math.PI * 2; // Distribute around tube + + const tubeRadius = radius * 0.12; + const centerRadius = radius; + + const cx = centerRadius * Math.cos(mainAngle); + const cy = centerRadius * Math.sin(mainAngle); + + points.push({ + x: cx + tubeRadius * Math.cos(tubeAngle) * Math.cos(mainAngle), + y: cy + tubeRadius * Math.cos(tubeAngle) * Math.sin(mainAngle), + z: tubeRadius * Math.sin(tubeAngle), + }); + } + + // Create the handle + for (let i = 0; i < handlePoints; i++) { + const t = i / handlePoints; + const handleAngle = (i * 13) % 12 / 12 * Math.PI * 2; + + // Handle position (extends from bottom-right of ring) + const handleStart = radius * 0.7; + const hx = handleStart + t * handleLength; + const hy = handleStart + t * handleLength; + + points.push({ + x: hx + handleWidth * Math.cos(handleAngle) * 0.3, + y: hy + handleWidth * Math.cos(handleAngle) * 0.3, + z: handleWidth * Math.sin(handleAngle), + }); + } + + return points; +}; + +export const SEARCH_POINTS = scaleShape( + normalizeShape(generateSearchPoints(80)), + 1.0, +); diff --git a/src/components/onboarding/shapes/sphere.ts b/src/components/onboarding/shapes/sphere.ts new file mode 100644 index 0000000..82a6a13 --- /dev/null +++ b/src/components/onboarding/shapes/sphere.ts @@ -0,0 +1,19 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape } from './utils'; + +// Sphere +const generateSpherePoints = (radius: number): Point3D[] => { + const points: Point3D[] = []; + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi } = fibonacciPoint(i, N_POINTS); + points.push({ + x: radius * Math.sin(phi) * Math.cos(theta), + y: radius * Math.sin(phi) * Math.sin(theta), + z: radius * Math.cos(phi), + }); + } + return points; +}; + +export const SPHERE_POINTS = normalizeShape(generateSpherePoints(100)); diff --git a/src/components/onboarding/shapes/star.ts b/src/components/onboarding/shapes/star.ts new file mode 100644 index 0000000..dfb13d4 --- /dev/null +++ b/src/components/onboarding/shapes/star.ts @@ -0,0 +1,31 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape, scaleShape } from './utils'; + +// Star shape - for "Welcome" page +const generateStarPoints = (outerRadius: number, innerRadius: number): Point3D[] => { + const points: Point3D[] = []; + const numPoints = 5; // 5-pointed star + + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi, t } = fibonacciPoint(i, N_POINTS); + + // Create star cross-section + const angle = theta * numPoints; + const radiusFactor = 0.5 + 0.5 * Math.cos(angle); + const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor; + + const sinPhi = Math.sin(phi); + points.push({ + x: radius * sinPhi * Math.cos(theta), + y: radius * sinPhi * Math.sin(theta), + z: radius * Math.cos(phi) * 0.3, // Flatten z for star shape + }); + } + return points; +}; + +export const STAR_POINTS = scaleShape( + normalizeShape(generateStarPoints(100, 40)), + 0.9, +); diff --git a/src/components/onboarding/shapes/torus.ts b/src/components/onboarding/shapes/torus.ts new file mode 100644 index 0000000..6146f9d --- /dev/null +++ b/src/components/onboarding/shapes/torus.ts @@ -0,0 +1,48 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { normalizeShape, scaleShape } from './utils'; + +// Torus - uniform grid with same index correspondence +const generateTorusPoints = (major: number, minor: number): Point3D[] => { + const points: Point3D[] = []; + + // Calculate approximate grid dimensions + const ratio = major / minor; + const minorSegments = Math.round(Math.sqrt(N_POINTS / ratio)); + const majorSegments = Math.round(N_POINTS / minorSegments); + + let idx = 0; + for (let i = 0; i < majorSegments && idx < N_POINTS; i++) { + const u = (i / majorSegments) * Math.PI * 2; + + for (let j = 0; j < minorSegments && idx < N_POINTS; j++) { + const v = (j / minorSegments) * Math.PI * 2; + + points.push({ + x: (major + minor * Math.cos(v)) * Math.cos(u), + y: (major + minor * Math.cos(v)) * Math.sin(u), + z: minor * Math.sin(v), + }); + idx++; + } + } + + // Fill missing points if necessary + while (points.length < N_POINTS) { + const t = points.length / N_POINTS; + const u = t * Math.PI * 2 * majorSegments; + const v = t * Math.PI * 2 * minorSegments; + points.push({ + x: (major + minor * Math.cos(v)) * Math.cos(u), + y: (major + minor * Math.cos(v)) * Math.sin(u), + z: minor * Math.sin(v), + }); + } + + return points.slice(0, N_POINTS); +}; + +export const TORUS_POINTS = scaleShape( + normalizeShape(generateTorusPoints(50, 25)), + 1.2, +); diff --git a/src/components/onboarding/shapes/types.ts b/src/components/onboarding/shapes/types.ts new file mode 100644 index 0000000..d349cec --- /dev/null +++ b/src/components/onboarding/shapes/types.ts @@ -0,0 +1 @@ +export type Point3D = { x: number; y: number; z: number }; diff --git a/src/components/onboarding/shapes/utils.ts b/src/components/onboarding/shapes/utils.ts new file mode 100644 index 0000000..c1a68f8 --- /dev/null +++ b/src/components/onboarding/shapes/utils.ts @@ -0,0 +1,54 @@ +import { GOLDEN_RATIO, TARGET_HEIGHT } from './constants'; +import { type Point3D } from './types'; + +// Generate Fibonacci points on unit sphere, then map to shape +export const fibonacciPoint = ( + i: number, + total: number, +): { theta: number; phi: number; t: number } => { + const t = i / total; + const theta = (2 * Math.PI * i) / GOLDEN_RATIO; + const phi = Math.acos(1 - 2 * t); + return { theta, phi, t }; +}; + +export const normalizeShape = (points: Point3D[]): Point3D[] => { + // Find min/max for each axis + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + let minZ = Infinity, + maxZ = -Infinity; + + for (const p of points) { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + minZ = Math.min(minZ, p.z); + maxZ = Math.max(maxZ, p.z); + } + + // Calculate current dimensions + const currentHeight = maxY - minY; + const scale = TARGET_HEIGHT / currentHeight; + + // Center and scale uniformly + const centerY = (minY + maxY) / 2; + + return points.map(p => ({ + x: (p.x - (minX + maxX) / 2) * scale, + y: (p.y - centerY) * scale, + z: (p.z - (minZ + maxZ) / 2) * scale, + })); +}; + +// Additional scale for single shape +export const scaleShape = (points: Point3D[], factor: number): Point3D[] => { + return points.map(p => ({ + x: p.x * factor, + y: p.y * factor, + z: p.z * factor, + })); +}; diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index fc68202..f807ea5 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -25,6 +25,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { mmkvStorage } from '../services/mmkvStorage'; +import { ShapeAnimation } from '../components/onboarding/ShapeAnimation'; const { width, height } = Dimensions.get('window'); @@ -263,6 +264,29 @@ const OnboardingScreen = () => { transform: [{ scale: buttonScale.value }], })); + // Animated opacity for button and swipe indicator based on scroll + const lastSlideStart = (onboardingData.length - 1) * width; + + const buttonOpacityStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollX.value, + [lastSlideStart - width * 0.3, lastSlideStart], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const swipeOpacityStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollX.value, + [lastSlideStart - width * 0.3, lastSlideStart], + [1, 0], + Extrapolation.CLAMP + ); + return { opacity }; + }); + const handlePressIn = () => { buttonScale.value = withSpring(0.95, { damping: 15, stiffness: 400 }); }; @@ -276,6 +300,9 @@ const OnboardingScreen = () => { + {/* Shape Animation Background */} + + {/* Header */} { ))} - {/* Animated Button */} - - - - {currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Continue'} - + {/* Button and Swipe indicator with crossfade based on scroll */} + + {/* Swipe Indicator - fades out on last slide */} + + Swipe to continue + - + + {/* Get Started Button - fades in on last slide */} + + + + Get Started + + + + @@ -381,8 +417,9 @@ const styles = StyleSheet.create({ slide: { width, flex: 1, - justifyContent: 'center', + justifyContent: 'flex-start', // Align to top paddingHorizontal: 32, + paddingTop: '20%', // Push text down slightly from header }, textContainer: { alignItems: 'flex-start', @@ -437,6 +474,34 @@ const styles = StyleSheet.create({ color: '#0A0A0A', letterSpacing: 0.3, }, + swipeIndicator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 18, + gap: 8, + }, + swipeText: { + fontSize: 14, + fontWeight: '500', + color: 'rgba(255, 255, 255, 0.4)', + letterSpacing: 0.3, + }, + swipeArrow: { + fontSize: 18, + color: 'rgba(255, 255, 255, 0.4)', + }, + footerButtonContainer: { + height: 56, + position: 'relative', + }, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, }); export default OnboardingScreen; \ No newline at end of file