mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
slight onboarding screen Ui change
This commit is contained in:
parent
edeb6ebe3c
commit
0f1d736716
17 changed files with 879 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
64
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
291
src/components/onboarding/ShapeAnimation.tsx
Normal file
291
src/components/onboarding/ShapeAnimation.tsx
Normal 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;
|
||||
8
src/components/onboarding/shapes/constants.ts
Normal file
8
src/components/onboarding/shapes/constants.ts
Normal 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;
|
||||
35
src/components/onboarding/shapes/cube.ts
Normal file
35
src/components/onboarding/shapes/cube.ts
Normal 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,
|
||||
);
|
||||
35
src/components/onboarding/shapes/heart.ts
Normal file
35
src/components/onboarding/shapes/heart.ts
Normal 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));
|
||||
28
src/components/onboarding/shapes/index.ts
Normal file
28
src/components/onboarding/shapes/index.ts
Normal 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),
|
||||
);
|
||||
96
src/components/onboarding/shapes/plugin.ts
Normal file
96
src/components/onboarding/shapes/plugin.ts
Normal 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,
|
||||
);
|
||||
57
src/components/onboarding/shapes/search.ts
Normal file
57
src/components/onboarding/shapes/search.ts
Normal 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,
|
||||
);
|
||||
19
src/components/onboarding/shapes/sphere.ts
Normal file
19
src/components/onboarding/shapes/sphere.ts
Normal 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));
|
||||
31
src/components/onboarding/shapes/star.ts
Normal file
31
src/components/onboarding/shapes/star.ts
Normal 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,
|
||||
);
|
||||
48
src/components/onboarding/shapes/torus.ts
Normal file
48
src/components/onboarding/shapes/torus.ts
Normal 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,
|
||||
);
|
||||
1
src/components/onboarding/shapes/types.ts
Normal file
1
src/components/onboarding/shapes/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Point3D = { x: number; y: number; z: number };
|
||||
54
src/components/onboarding/shapes/utils.ts
Normal file
54
src/components/onboarding/shapes/utils.ts
Normal 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,
|
||||
}));
|
||||
};
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue