slight onboarding screen Ui change

This commit is contained in:
tapframe 2026-01-05 23:42:51 +05:30
parent edeb6ebe3c
commit 0f1d736716
17 changed files with 879 additions and 17 deletions

View file

@ -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

View file

@ -1,5 +1,6 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true"
}
"newArchEnabled": "true",
"ios.deploymentTarget": "16.0"
}

64
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<number>;
}
// Single colored path component
const ColoredPath = ({
morphPath,
colorIndex,
scrollX,
windowWidth,
windowHeight,
}: {
morphPath: any;
colorIndex: number;
scrollX: SharedValue<number>;
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 (
<Group opacity={1}>
<Path path={morphPath} style="fill">
<LinearGradient
start={vec(0, windowHeight * 0.4)}
end={vec(windowWidth, windowHeight * 0.9)}
colors={colors}
/>
<BlurMask blur={5} style="solid" />
</Path>
</Group>
);
};
export const ShapeAnimation: React.FC<ShapeAnimationProps> = ({ 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 (
<Canvas
style={[
styles.canvas,
{
width: windowWidth,
height: windowHeight,
},
]}>
{/* Background radial gradient blurred */}
<Circle
cx={windowWidth / 2}
cy={windowHeight * 0.65}
r={windowWidth * 0.6}>
<RadialGradient
c={vec(windowWidth / 2, windowHeight * 0.65)}
r={windowWidth * 0.6}
colors={['#ffffff20', 'transparent']}
/>
<Blur blur={60} />
</Circle>
{/* Layer 0: Gold (Star) */}
<Path path={path0} style="fill">
<LinearGradient
start={vec(0, windowHeight * 0.4)}
end={vec(windowWidth, windowHeight * 0.9)}
colors={COLOR_PALETTES[0]}
/>
<BlurMask blur={5} style="solid" />
</Path>
{/* Layer 1: Purple (Plugin) */}
<Path path={path1} style="fill">
<LinearGradient
start={vec(0, windowHeight * 0.4)}
end={vec(windowWidth, windowHeight * 0.9)}
colors={COLOR_PALETTES[1]}
/>
<BlurMask blur={5} style="solid" />
</Path>
{/* Layer 2: Cyan (Search) */}
<Path path={path2} style="fill">
<LinearGradient
start={vec(0, windowHeight * 0.4)}
end={vec(windowWidth, windowHeight * 0.9)}
colors={COLOR_PALETTES[2]}
/>
<BlurMask blur={5} style="solid" />
</Path>
{/* Layer 3: Pink (Heart) */}
<Path path={path3} style="fill">
<LinearGradient
start={vec(0, windowHeight * 0.4)}
end={vec(windowWidth, windowHeight * 0.9)}
colors={COLOR_PALETTES[3]}
/>
<BlurMask blur={5} style="solid" />
</Path>
</Canvas>
);
};
const styles = StyleSheet.create({
canvas: {
position: 'absolute',
top: 0,
left: 0,
},
});
export default ShapeAnimation;

View file

@ -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;

View file

@ -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,
);

View file

@ -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));

View file

@ -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),
);

View file

@ -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,
);

View file

@ -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,
);

View file

@ -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));

View file

@ -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,
);

View file

@ -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,
);

View file

@ -0,0 +1 @@
export type Point3D = { x: number; y: number; z: number };

View file

@ -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,
}));
};

View file

@ -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 = () => {
<StatusBar barStyle="light-content" backgroundColor="#0A0A0A" translucent />
<View style={styles.fullScreenContainer}>
{/* Shape Animation Background */}
<ShapeAnimation scrollX={scrollX} />
{/* Header */}
<Animated.View
entering={FadeIn.delay(300).duration(600)}
@ -321,19 +348,28 @@ const OnboardingScreen = () => {
))}
</View>
{/* Animated Button */}
<TouchableOpacity
onPress={handleNext}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[styles.button, buttonStyle]}>
<Text style={styles.buttonText}>
{currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Continue'}
</Text>
{/* Button and Swipe indicator with crossfade based on scroll */}
<View style={styles.footerButtonContainer}>
{/* Swipe Indicator - fades out on last slide */}
<Animated.View style={[styles.swipeIndicator, styles.absoluteFill, swipeOpacityStyle]}>
<Text style={styles.swipeText}>Swipe to continue</Text>
<Text style={styles.swipeArrow}></Text>
</Animated.View>
</TouchableOpacity>
{/* Get Started Button - fades in on last slide */}
<Animated.View style={[styles.absoluteFill, buttonOpacityStyle]}>
<TouchableOpacity
onPress={handleGetStarted}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[styles.button, buttonStyle]}>
<Text style={styles.buttonText}>Get Started</Text>
</Animated.View>
</TouchableOpacity>
</Animated.View>
</View>
</Animated.View>
</View>
</View>
@ -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;