Add beenvoice mobile companion app with full dark mode support.

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>
This commit is contained in:
2026-06-17 22:36:37 -04:00
parent 8a7a8df477
commit 14c880123c
93 changed files with 8849 additions and 7849 deletions
+134
View File
@@ -0,0 +1,134 @@
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),
},
});