
Первый запуск и онбординг — стартовый кадр приложения.
npx astra-rn add welcome-v2pnpm dlx astra-rn add welcome-v2yarn dlx astra-rn add welcome-v2bunx --bun astra-rn add welcome-v2npm install @shopify/react-native-skia react-native-svgpnpm add @shopify/react-native-skia react-native-svgyarn add @shopify/react-native-skia react-native-svgbun add @shopify/react-native-skia react-native-svgimport { Canvas, Fill, Shader, Skia } from "@shopify/react-native-skia";
import React, { memo, useCallback, useMemo, useState } from "react";
import {
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
} from "react-native";
import Svg, { Circle, Rect } from "react-native-svg";
import {
ACTION_HEIGHT,
ACTION_RADIUS,
COLORS,
CONTENT_HORIZONTAL_PADDING,
DEFAULT_ACTION_LABEL,
DEFAULT_FOOTER_ACTION_LABEL,
DEFAULT_FOOTER_PROMPT,
DEFAULT_LEGAL_PREFIX,
DEFAULT_LEGAL_SEPARATOR,
DEFAULT_LEGAL_SUFFIX,
DEFAULT_PRIVACY_LABEL,
DEFAULT_SUBTITLE,
DEFAULT_TERMS_LABEL,
DEFAULT_TITLE,
LOGO_SIZE,
ORB_BACKDROP,
ORB_BLUE,
ORB_BLUE_ANGLE,
ORB_BLUE_GAIN,
ORB_BLUE_RING,
ORB_BLUE_SPREAD,
ORB_BLUE_WIDTH,
ORB_BLUE_OFFSET,
ORB_CENTER,
ORB_CLOUD,
ORB_CLOUD_SCALE,
ORB_DOME_FADE,
ORB_EXPOSURE,
ORB_GRAIN,
ORB_GRAIN_SCALE,
ORB_HAZE_GAIN,
ORB_HAZE_RING,
ORB_HAZE_WIDTH,
ORB_SCALE,
ORB_SHADER,
ORB_WARM,
ORB_WARM_ANGLE,
ORB_WARM_GAIN,
ORB_WARM_OFFSET,
ORB_WARM_RING,
ORB_WARM_SPREAD,
ORB_WARM_WIDTH,
} from "./const";
import type { IWelcomeScreenV2 } from "./types";
const WelcomeScreenV2: React.FC<IWelcomeScreenV2> = ({
title = DEFAULT_TITLE,
subtitle = DEFAULT_SUBTITLE,
actionLabel = DEFAULT_ACTION_LABEL,
footerPrompt = DEFAULT_FOOTER_PROMPT,
footerActionLabel = DEFAULT_FOOTER_ACTION_LABEL,
legalPrefix = DEFAULT_LEGAL_PREFIX,
termsLabel = DEFAULT_TERMS_LABEL,
legalSeparator = DEFAULT_LEGAL_SEPARATOR,
privacyLabel = DEFAULT_PRIVACY_LABEL,
legalSuffix = DEFAULT_LEGAL_SUFFIX,
logo,
style,
onActionPress,
onFooterPress,
onTermsPress,
onPrivacyPress,
}: IWelcomeScreenV2) => {
const { width, height } = useWindowDimensions();
const [pressed, setPressed] = useState<boolean>(false);
const effect = useMemo(() => Skia.RuntimeEffect.Make(ORB_SHADER), []);
const uniforms = useMemo(
() => ({
resolution: [width, height],
center: ORB_CENTER,
orbScale: ORB_SCALE,
backdrop: ORB_BACKDROP,
warmColor: ORB_WARM,
blueColor: ORB_BLUE,
warmOffset: ORB_WARM_OFFSET,
warmRing: ORB_WARM_RING,
warmWidth: ORB_WARM_WIDTH,
warmGain: ORB_WARM_GAIN,
warmAngle: ORB_WARM_ANGLE,
warmSpread: ORB_WARM_SPREAD,
blueOffset: ORB_BLUE_OFFSET,
blueRing: ORB_BLUE_RING,
blueWidth: ORB_BLUE_WIDTH,
blueGain: ORB_BLUE_GAIN,
blueAngle: ORB_BLUE_ANGLE,
blueSpread: ORB_BLUE_SPREAD,
hazeRing: ORB_HAZE_RING,
hazeWidth: ORB_HAZE_WIDTH,
hazeGain: ORB_HAZE_GAIN,
cloud: ORB_CLOUD,
cloudScale: ORB_CLOUD_SCALE,
domeFade: ORB_DOME_FADE,
exposure: ORB_EXPOSURE,
grain: ORB_GRAIN,
grainScale: ORB_GRAIN_SCALE,
}),
[height, width],
);
const handlePressIn = useCallback(() => setPressed(true), []);
const handlePressOut = useCallback(() => setPressed(false), []);
return (
<View style={[styles.container, style]}>
{effect ? (
<Canvas style={StyleSheet.absoluteFill}>
<Fill>
<Shader source={effect} uniforms={uniforms} />
</Fill>
</Canvas>
) : null}
<View style={styles.content}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
<View style={styles.actionGlow}>
<Pressable
accessibilityRole="button"
accessibilityLabel={actionLabel}
onPress={onActionPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[styles.action, pressed ? styles.actionPressed : null]}
>
<View style={styles.actionInner}>
<Text style={styles.actionLabel} numberOfLines={1}>
{actionLabel}
</Text>
</View>
</Pressable>
</View>
<Text style={styles.footer}>
{footerPrompt}{" "}
<Text
accessibilityRole="link"
onPress={onFooterPress}
style={styles.footerAction}
>
{footerActionLabel}
</Text>
</Text>
<Text style={styles.legal}>
{legalPrefix}{" "}
<Text
accessibilityRole="link"
onPress={onTermsPress}
style={styles.legalLink}
>
{termsLabel}
</Text>{" "}
{legalSeparator}{" "}
<Text
accessibilityRole="link"
onPress={onPrivacyPress}
style={styles.legalLink}
>
{privacyLabel}
</Text>
{legalSuffix}
</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.screen,
},
content: {
flex: 1,
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: CONTENT_HORIZONTAL_PADDING,
paddingBottom: 40,
},
logo: {
marginBottom: 26,
},
title: {
fontSize: 28,
lineHeight: 34,
fontWeight: "700",
letterSpacing: -0.6,
textAlign: "center",
color: COLORS.title,
},
subtitle: {
marginTop: 12,
maxWidth: 320,
fontSize: 17,
lineHeight: 24,
letterSpacing: -0.2,
textAlign: "center",
color: COLORS.subtitle,
},
actionGlow: {
width: "100%",
marginTop: 32,
borderRadius: ACTION_RADIUS,
shadowColor: "#ffffff",
shadowOpacity: 0.3,
shadowRadius: 26,
shadowOffset: { width: 0, height: 0 },
elevation: 12,
},
action: {
width: "100%",
height: ACTION_HEIGHT,
borderRadius: ACTION_RADIUS,
backgroundColor: COLORS.action,
overflow: "hidden",
},
actionPressed: {
backgroundColor: COLORS.actionPressed,
},
actionInner: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 18,
},
actionLabel: {
fontSize: 17,
fontWeight: "600",
letterSpacing: -0.3,
color: COLORS.actionLabel,
},
footer: {
marginTop: 24,
fontSize: 15.5,
letterSpacing: -0.2,
textAlign: "center",
color: COLORS.footer,
},
footerAction: {
fontWeight: "700",
color: COLORS.footerAction,
},
legal: {
marginTop: 26,
maxWidth: 300,
fontSize: 12.5,
lineHeight: 18,
textAlign: "center",
color: COLORS.legal,
},
legalLink: {
color: COLORS.legalLink,
},
});
export { WelcomeScreenV2 };
export type { IGlyph, IWelcomeOrb, IWelcomeScreenV2 } from "./types";
export default memo(WelcomeScreenV2);export const DEFAULT_TITLE = "Welcome to Beside";
export const DEFAULT_SUBTITLE =
"Starting today, your AI phone assistant saves you 2 hours daily.";
export const DEFAULT_ACTION_LABEL = "Get Started";
export const DEFAULT_FOOTER_PROMPT = "Already have an account?";
export const DEFAULT_FOOTER_ACTION_LABEL = "Log in";
export const DEFAULT_LEGAL_PREFIX = "By tapping Get Started, I agree with the";
export const DEFAULT_TERMS_LABEL = "Terms of Service";
export const DEFAULT_LEGAL_SEPARATOR = "and";
export const DEFAULT_PRIVACY_LABEL = "Privacy Policy";
export const DEFAULT_LEGAL_SUFFIX = ".";
export const ORB_CENTER = [0.5, 0.42];
export const ORB_SCALE = [0.62, 0.5];
export const ORB_BACKDROP = [0.042, 0.042, 0.05];
export const ORB_WARM = [0.965, 0.925, 0.855];
export const ORB_BLUE = [0.09, 0.235, 0.98];
export const ORB_WARM_OFFSET = [0.07, 0.02];
export const ORB_WARM_RING = 0.9;
export const ORB_WARM_WIDTH = 0.12;
export const ORB_WARM_GAIN = 0.5;
export const ORB_WARM_ANGLE = 88;
export const ORB_WARM_SPREAD = 0.7;
export const ORB_BLUE_OFFSET = [-0.09, 0.06];
export const ORB_BLUE_RING = 0.72;
export const ORB_BLUE_WIDTH = 0.2;
export const ORB_BLUE_GAIN = 1.7;
export const ORB_BLUE_ANGLE = 0;
export const ORB_BLUE_SPREAD = 1.2;
export const ORB_HAZE_RING = 0.78;
export const ORB_HAZE_WIDTH = 0.5;
export const ORB_HAZE_GAIN = 0.09;
export const ORB_CLOUD = 0.055;
export const ORB_CLOUD_SCALE = 1.7;
export const ORB_DOME_FADE = 0.08;
export const ORB_EXPOSURE = 1.25;
export const ORB_GRAIN = 0.14;
export const ORB_GRAIN_SCALE = 2;
export const ORB_SHADER = `
uniform float2 resolution;
uniform float2 center;
uniform float2 orbScale;
uniform float3 backdrop;
uniform float3 warmColor;
uniform float3 blueColor;
uniform float2 warmOffset;
uniform float warmRing;
uniform float warmWidth;
uniform float warmGain;
uniform float warmAngle;
uniform float warmSpread;
uniform float2 blueOffset;
uniform float blueRing;
uniform float blueWidth;
uniform float blueGain;
uniform float blueAngle;
uniform float blueSpread;
uniform float hazeRing;
uniform float hazeWidth;
uniform float hazeGain;
uniform float cloud;
uniform float cloudScale;
uniform float domeFade;
uniform float exposure;
uniform float grain;
uniform float grainScale;
float hash1(float2 p) {
return fract(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);
}
float vnoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
float a = hash1(i);
float b = hash1(i + float2(1.0, 0.0));
float c = hash1(i + float2(0.0, 1.0));
float d = hash1(i + float2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(float2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * vnoise(p);
p = p * 2.03;
a = a * 0.2;
}
return v;
}
float band(float r, float ring, float width) {
float t = (r - ring) / width;
return exp(-t * t);
}
float lobe(float2 dir, float deg, float spread) {
float a = radians(deg);
float2 axis = float2(sin(a), -cos(a));
return pow(clamp(0.5 + 0.5 * dot(dir, axis), 0.0, 1.0), spread);
}
half4 main(float2 fragCoord) {
float2 q = (fragCoord - center * resolution) / (orbScale * resolution.x);
float base = length(q);
float2 dir = base > 0.0001 ? q / base : float2(0.0, -1.0);
float nWarm = fbm(q * cloudScale + 4.1) - 0.47;
float nBlue = fbm(q * cloudScale + 13.7) - 0.47;
float rWarm = length(q - warmOffset) + nWarm * cloud;
float rBlue = length(q - blueOffset) + nBlue * cloud;
float dome = smoothstep(domeFade, -0.6, q.y);
float warm = band(rWarm, warmRing, warmWidth) * lobe(dir, warmAngle, warmSpread) * warmGain;
float blue = band(rBlue, blueRing, blueWidth) * lobe(dir, blueAngle, blueSpread) * blueGain;
float haze = band(base, hazeRing, hazeWidth) * hazeGain;
float3 glow = blueColor * blue + warmColor * (warm + haze);
float3 color = backdrop + glow * dome;
color = 1.0 - exp(-color * exposure);
float coarse = hash1(floor(fragCoord / grainScale));
float fine = hash1(fragCoord * 1.37 + 7.1);
float n = mix(coarse, fine, 0.35) - 0.5;
float luma = dot(color, float3(0.299, 0.587, 0.114));
color += n * grain * (0.18 + 1.5 * luma);
return half4(half3(clamp(color, 0.0, 1.0)), 1.0);
}
`;
export const LOGO_SIZE = 46;
export const CONTENT_HORIZONTAL_PADDING = 24;
export const ACTION_HEIGHT = 58;
export const ACTION_RADIUS = ACTION_HEIGHT / 2;
export const COLORS = {
screen: "#0e0e11",
title: "#ffffff",
subtitle: "#9b9ba1",
action: "#ffffff",
actionPressed: "#e6e6ea",
actionLabel: "#0b0b0d",
footer: "#8e8e94",
footerAction: "#ffffff",
legal: "#5c5c62",
legalLink: "#8e8e94",
logo: "#ffffff",
} as const;import type { StyleProp, ViewStyle } from "react-native";
export interface IGlyph {
size?: number;
color?: string;
}
export interface IWelcomeOrb {
width: number;
height: number;
}
export interface IWelcomeScreenV2 {
title?: string;
subtitle?: string;
actionLabel?: string;
footerPrompt?: string;
footerActionLabel?: string;
legalPrefix?: string;
termsLabel?: string;
legalSeparator?: string;
privacyLabel?: string;
legalSuffix?: string;
logo?: React.ReactNode;
style?: StyleProp<ViewStyle>;
onActionPress?: () => void;
onFooterPress?: () => void;
onTermsPress?: () => void;
onPrivacyPress?: () => void;
}