14c880123c
Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities. Co-authored-by: Cursor <cursoragent@cursor.com>
135 lines
4.0 KiB
TypeScript
135 lines
4.0 KiB
TypeScript
import { useEffect, useMemo } from "react";
|
|
import { StyleSheet, useWindowDimensions, View, type ViewProps } from "react-native";
|
|
import Animated, {
|
|
Easing,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withRepeat,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import Svg, { Circle, Defs, Line, RadialGradient, Stop } from "react-native-svg";
|
|
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { blobAnimation, blobDiameter } from "@/lib/beenvoice-theme";
|
|
import { getBackgroundTokens } from "@/lib/theme-palette";
|
|
|
|
export function BrandBackground({ style, ...props }: ViewProps) {
|
|
const { colorScheme } = useAppTheme();
|
|
const tokens = useMemo(() => getBackgroundTokens(colorScheme), [colorScheme]);
|
|
const { width, height } = useWindowDimensions();
|
|
const cx = width / 2;
|
|
const cy = height / 2;
|
|
|
|
const gridLines = useMemo(() => {
|
|
const vertical: Array<{ key: string; x: number }> = [];
|
|
const horizontal: Array<{ key: string; y: number }> = [];
|
|
for (let x = 0; x <= width; x += tokens.gridSize) {
|
|
vertical.push({ key: `v-${x}`, x });
|
|
}
|
|
for (let y = 0; y <= height; y += tokens.gridSize) {
|
|
horizontal.push({ key: `h-${y}`, y });
|
|
}
|
|
return { vertical, horizontal };
|
|
}, [width, height, tokens.gridSize]);
|
|
|
|
return (
|
|
<View
|
|
style={[styles.root, { backgroundColor: tokens.background }, style]}
|
|
pointerEvents="none"
|
|
{...props}
|
|
>
|
|
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
|
|
{gridLines.vertical.map((line) => (
|
|
<Line
|
|
key={line.key}
|
|
x1={line.x}
|
|
y1={0}
|
|
x2={line.x}
|
|
y2={height}
|
|
stroke={tokens.gridLine}
|
|
strokeWidth={1}
|
|
/>
|
|
))}
|
|
{gridLines.horizontal.map((line) => (
|
|
<Line
|
|
key={line.key}
|
|
x1={0}
|
|
y1={line.y}
|
|
x2={width}
|
|
y2={line.y}
|
|
stroke={tokens.gridLine}
|
|
strokeWidth={1}
|
|
/>
|
|
))}
|
|
</Svg>
|
|
|
|
<AmbientBlob cx={cx} cy={cy} blobCore={tokens.blobCore} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AmbientBlob({ cx, cy, blobCore }: { cx: number; cy: number; blobCore: string }) {
|
|
const progress = useSharedValue(0);
|
|
const r = blobDiameter / 2;
|
|
|
|
useEffect(() => {
|
|
progress.value = withRepeat(
|
|
withTiming(1, {
|
|
duration: blobAnimation.durationMs,
|
|
easing: Easing.inOut(Easing.ease),
|
|
}),
|
|
-1,
|
|
false,
|
|
);
|
|
}, [progress]);
|
|
|
|
const animatedStyle = useAnimatedStyle(() => {
|
|
const k = blobAnimation.keyframes;
|
|
const t = progress.value;
|
|
const seg = t < 0.33 ? 0 : t < 0.66 ? 1 : 2;
|
|
const local = seg === 0 ? t / 0.33 : seg === 1 ? (t - 0.33) / 0.33 : (t - 0.66) / 0.34;
|
|
const from = k[seg]!;
|
|
const to = k[seg + 1] ?? k[0]!;
|
|
const lerp = (a: number, b: number) => a + (b - a) * local;
|
|
|
|
return {
|
|
transform: [
|
|
{ translateX: lerp(from.translateX, to.translateX) },
|
|
{ translateY: lerp(from.translateY, to.translateY) },
|
|
{ scale: lerp(from.scale, to.scale) },
|
|
],
|
|
};
|
|
});
|
|
|
|
return (
|
|
<Animated.View style={[styles.blobLayer, animatedStyle]} pointerEvents="none">
|
|
<Svg width={blobDiameter * 1.6} height={blobDiameter * 1.6}>
|
|
<Defs>
|
|
<RadialGradient id="blob-a" cx="50%" cy="50%" r="50%">
|
|
<Stop offset="0%" stopColor={blobCore} stopOpacity={0.9} />
|
|
<Stop offset="38%" stopColor={blobCore} stopOpacity={0.35} />
|
|
<Stop offset="62%" stopColor={blobCore} stopOpacity={0.1} />
|
|
<Stop offset="100%" stopColor={blobCore} stopOpacity={0} />
|
|
</RadialGradient>
|
|
</Defs>
|
|
<Circle cx={blobDiameter * 0.8} cy={blobDiameter * 0.8} r={r} fill="url(#blob-a)" />
|
|
</Svg>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
...StyleSheet.absoluteFill,
|
|
},
|
|
blobLayer: {
|
|
position: "absolute",
|
|
left: "50%",
|
|
top: "50%",
|
|
width: blobDiameter * 1.6,
|
|
height: blobDiameter * 1.6,
|
|
marginLeft: -(blobDiameter * 0.8),
|
|
marginTop: -(blobDiameter * 0.8),
|
|
},
|
|
});
|