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
co-authored by Cursor
parent 8a7a8df477
commit 14c880123c
93 changed files with 8849 additions and 7849 deletions
+34
View File
@@ -0,0 +1,34 @@
import { StyleSheet, View, type ViewProps } from "react-native";
import { BrandBackground } from "@/components/BrandBackground";
/** Auth screens — brand grid/blob behind content. */
export function AuthBackground({ style, children, ...props }: ViewProps) {
return (
<View style={[styles.root, style]} {...props}>
<BrandBackground />
<View style={styles.content}>{children}</View>
</View>
);
}
/** App tab/stack screens — brand grid/blob behind content (native tabs block the root layer). */
export function AppBackground({ style, children, ...props }: ViewProps) {
return (
<View style={[styles.root, style]} {...props}>
<BrandBackground />
<View style={styles.content}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "transparent",
},
content: {
flex: 1,
backgroundColor: "transparent",
},
});
+174
View File
@@ -0,0 +1,174 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import {
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { LogoMark } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppLock } from "@/contexts/AppLockContext";
import { useAppTheme } from "@/contexts/ThemeContext";
export function AppLockOverlay() {
const { colors } = useAppTheme();
const {
enabled,
isLocked,
biometricEnabled,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
} = useAppLock();
const [pin, setPin] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!isLocked) {
setPin("");
setError("");
}
}, [isLocked]);
useEffect(() => {
if (!enabled || !isLocked || !biometricEnabled || !biometricAvailable) {
return;
}
void unlockWithBiometric().then((success) => {
if (!success) return;
setPin("");
setError("");
});
}, [enabled, isLocked, biometricEnabled, biometricAvailable, unlockWithBiometric]);
if (!enabled || !isLocked) {
return null;
}
async function submitPin() {
const success = await unlockWithPin(pin);
if (success) {
setPin("");
setError("");
return;
}
setError("Incorrect PIN");
setPin("");
}
async function tryBiometric() {
const success = await unlockWithBiometric();
if (!success) {
setError(`Could not unlock with ${biometricLabel}`);
}
}
return (
<Modal visible animationType="fade" transparent={false}>
<View style={[styles.screen, { backgroundColor: colors.background }]}>
<View style={styles.content}>
<LogoMark size={56} />
<Text style={[styles.title, { color: colors.foreground }]}>beenvoice is locked</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Enter your PIN to continue
</Text>
<TextInput
value={pin}
onChangeText={(value) => {
setError("");
setPin(value.replace(/\D/g, "").slice(0, 6));
}}
keyboardType="number-pad"
secureTextEntry
maxLength={6}
style={[
styles.pinInput,
{
color: colors.foreground,
borderColor: colors.border,
backgroundColor: colors.card,
},
]}
placeholder="PIN"
placeholderTextColor={colors.mutedForeground}
onSubmitEditing={() => void submitPin()}
/>
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
{biometricEnabled && biometricAvailable ? (
<Pressable
accessibilityRole="button"
onPress={() => void tryBiometric()}
style={styles.biometricButton}
>
<Ionicons name="finger-print-outline" size={20} color={colors.primary} />
<Text style={[styles.biometricLabel, { color: colors.primary }]}>
Unlock with {biometricLabel}
</Text>
</Pressable>
) : null}
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
},
content: {
alignItems: "center",
gap: spacing.md,
},
title: {
fontSize: 22,
fontFamily: fonts.heading,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
lineHeight: 20,
},
pinInput: {
width: "100%",
maxWidth: 280,
borderWidth: 1,
borderRadius: 12,
minHeight: 52,
paddingHorizontal: spacing.md,
fontSize: 24,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
letterSpacing: 8,
},
error: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
biometricButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
},
biometricLabel: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
});
+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),
},
});
+64
View File
@@ -0,0 +1,64 @@
import { router } from "expo-router";
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { fonts } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatElapsedHoursMinutes } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
/** Green dot + elapsed time when a timer is running; tappable to open the clock. */
export function ClockedInIndicator() {
const { colors } = useAppTheme();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
if (!running) return null;
const label = formatElapsedHoursMinutes(elapsed);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Clocked in, ${label}`}
hitSlop={8}
onPress={() => router.push("/(app)/timer")}
style={styles.hit}
>
<View style={[styles.row, { backgroundColor: colors.successBg }]}>
<View style={[styles.dot, { backgroundColor: colors.success }]} />
<Text style={[styles.time, { color: colors.foreground }]}>{label}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
hit: {
flexShrink: 0,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
paddingHorizontal: 10,
minHeight: 28,
borderRadius: 999,
},
dot: {
width: 7,
height: 7,
borderRadius: 4,
},
time: {
fontFamily: fonts.mono,
fontSize: 14,
lineHeight: 18,
fontVariant: ["tabular-nums"],
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
},
});
+150
View File
@@ -0,0 +1,150 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { hasConfiguredInstanceUrl } from "@/lib/accounts";
type CollapsibleServerFieldProps = {
defaultExpanded?: boolean;
};
function formatServerLabel(url: string) {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//, "");
}
}
export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleServerFieldProps) {
const { colors } = useAppTheme();
const insets = useSafeAreaInsets();
const { apiUrl, setInstanceUrl } = useAccounts();
const [expanded, setExpanded] = useState(defaultExpanded);
const [value, setValue] = useState(apiUrl);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
hasConfiguredInstanceUrl().then((configured) => {
if (!configured) setExpanded(true);
});
}, []);
useEffect(() => {
setValue(apiUrl);
}, [apiUrl]);
async function commit() {
const trimmed = value.trim();
if (!trimmed || trimmed === apiUrl) {
setError(null);
setExpanded(false);
return;
}
try {
const saved = await setInstanceUrl(trimmed);
setValue(saved);
setError(null);
setExpanded(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save server URL");
}
}
return (
<View
style={[
styles.wrapper,
{ paddingBottom: Math.max(insets.bottom, spacing.sm) },
]}
>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
onPress={() => setExpanded((open) => !open)}
hitSlop={8}
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
>
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
Server ·{" "}
<Text style={[styles.host, { color: colors.foreground }]}>
{formatServerLabel(apiUrl)}
</Text>
</Text>
<Ionicons
name={expanded ? "chevron-down" : "chevron-up"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{expanded ? (
<View
style={[
styles.panel,
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
]}
>
<Input
label="Server instance"
value={value}
onChangeText={setValue}
onBlur={commit}
onSubmitEditing={commit}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Use your Mac&apos;s LAN IP on a physical device.
</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
flexDirection: "column-reverse",
gap: spacing.sm,
paddingHorizontal: spacing.md,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
pressed: {
opacity: 0.7,
},
triggerText: {
fontSize: 13,
fontFamily: fonts.body,
},
host: {
fontFamily: fonts.mono,
fontSize: 13,
},
panel: {
borderWidth: 1,
borderRadius: 14,
padding: spacing.md,
gap: spacing.sm,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 16,
},
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { Link } from 'expo-router';
import { Link, type Href } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import type { ComponentProps } from 'react';
import { Platform } from 'react-native';
@@ -8,7 +8,7 @@ export function ExternalLink(props: Omit<ComponentProps<typeof Link>, 'href'> &
<Link
target="_blank"
{...props}
href={props.href}
href={props.href as Href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
+57
View File
@@ -0,0 +1,57 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import { fonts, radii } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type FilterChipProps = {
label: string;
active?: boolean;
onPress: () => void;
};
export function FilterChip({ label, active, onPress }: FilterChipProps) {
const { colors } = useAppTheme();
return (
<Pressable
accessibilityRole="button"
onPress={onPress}
style={[
styles.chip,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
active && { backgroundColor: colors.primary, borderColor: colors.primary },
]}
>
<View style={styles.chipInner}>
<Text
style={[
styles.label,
{ color: colors.mutedForeground },
active && { color: colors.primaryForeground },
]}
>
{label}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
chip: {
height: 32,
borderWidth: 1,
borderRadius: radii.pill,
overflow: "hidden",
},
chipInner: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 14,
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
});
+112
View File
@@ -0,0 +1,112 @@
import { BlurView } from "expo-blur";
import type { ReactNode } from "react";
import { Platform, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { blurIntensity, radius, shadowMd, shadowSm } from "@/lib/beenvoice-theme";
type GlassSurfaceProps = {
children: ReactNode;
style?: StyleProp<ViewStyle>;
radius?: number;
variant?: "card" | "stat";
};
export function GlassSurface({
children,
style,
radius: cornerRadius = radius.lg,
variant = "card",
}: GlassSurfaceProps) {
const { colors, isDark } = useAppTheme();
const flat = StyleSheet.flatten(style);
const isStat = variant === "stat";
return (
<View
style={[
styles.shell,
isStat ? styles.statShell : null,
{ borderRadius: cornerRadius, borderColor: colors.borderGlass },
isStat ? shadowMd : shadowSm,
flat,
Platform.OS === "android" ? { backgroundColor: colors.cardGlass } : null,
]}
>
{Platform.OS === "ios" ? (
<BlurView
intensity={blurIntensity.card}
tint={isDark ? "dark" : "light"}
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
/>
) : null}
<View
pointerEvents="none"
style={[
styles.fill,
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
]}
/>
<View style={styles.content}>{children}</View>
</View>
);
}
export function GlassChrome({
children,
style,
radius: cornerRadius = 0,
}: {
children?: ReactNode;
style?: StyleProp<ViewStyle>;
radius?: number;
}) {
const { colors, isDark } = useAppTheme();
return (
<View
style={[
styles.chromeShell,
{ borderRadius: cornerRadius, backgroundColor: colors.cardGlass },
StyleSheet.flatten(style),
]}
>
{Platform.OS === "ios" ? (
<BlurView
intensity={blurIntensity.chrome}
tint={isDark ? "dark" : "light"}
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
/>
) : null}
<View
pointerEvents="none"
style={[
styles.fill,
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
]}
/>
{children ? <View style={styles.content}>{children}</View> : null}
</View>
);
}
const styles = StyleSheet.create({
shell: {
overflow: "hidden",
borderWidth: StyleSheet.hairlineWidth * 2,
backgroundColor: "transparent",
},
statShell: {
borderWidth: 0,
},
chromeShell: {
overflow: "hidden",
},
fill: {
...StyleSheet.absoluteFill,
},
content: {
position: "relative",
zIndex: 2,
},
});
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { normalizeInstanceUrl } from "@/lib/instance-url";
type InstanceUrlFieldProps = {
onSaved?: (url: string) => void;
};
export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
const { colors } = useAppTheme();
const { apiUrl, setInstanceUrl } = useAccounts();
const [value, setValue] = useState(apiUrl);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setValue(apiUrl);
}, [apiUrl]);
async function commit() {
const trimmed = value.trim();
if (!trimmed || trimmed === apiUrl) {
setError(null);
return;
}
const normalized = normalizeInstanceUrl(trimmed);
if (!normalized) {
setError("Enter a valid URL like beenvoice.app or localhost:3000");
return;
}
try {
const saved = await setInstanceUrl(trimmed);
setValue(saved);
setError(null);
onSaved?.(saved);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save server URL");
}
}
return (
<View style={styles.wrapper}>
<Input
label="Server instance"
value={value}
onChangeText={setValue}
onBlur={commit}
onSubmitEditing={commit}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Point the app at your beenvoice server. Use your Mac&apos;s LAN IP on a physical device.
</Text>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.xs,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 16,
},
});
+45
View File
@@ -0,0 +1,45 @@
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BrandBackground } from "@/components/BrandBackground";
import { LogoMark } from "@/components/Logo";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts } from "@/constants/theme";
export function LoadingScreen({ message = "Loading…" }: { message?: string }) {
const insets = useSafeAreaInsets();
const { colors } = useAppTheme();
return (
<View style={styles.root}>
<BrandBackground />
<View
style={[
styles.container,
{ paddingTop: insets.top, paddingBottom: insets.bottom },
]}
>
<LogoMark />
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 12,
backgroundColor: "transparent",
},
message: {
fontSize: 15,
fontFamily: fonts.body,
},
});
+108
View File
@@ -0,0 +1,108 @@
import { Image } from "expo-image";
import { StyleSheet, Text, View, type ImageStyle, type ViewStyle } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts } from "@/constants/theme";
type LogoSize = "xs" | "sm" | "md" | "lg";
const widths: Record<LogoSize, number> = {
xs: 104,
sm: 140,
md: 180,
lg: 220,
};
type LogoProps = {
size?: LogoSize;
style?: ViewStyle;
/** Force the light wordmark for dark backgrounds (e.g. status bar chrome). */
onDark?: boolean;
};
/** Full beenvoice wordmark from web `public/beenvoice-logo.png` */
export function Logo({ size = "md", style, onDark }: LogoProps) {
const { isDark } = useAppTheme();
const width = widths[size];
const height = width * (436 / 2970);
const useDarkAsset = onDark ?? isDark;
return (
<View style={[styles.row, styles.noShrink, style]}>
<Image
source={
useDarkAsset
? require("@/assets/images/beenvoice-logo-dark.png")
: require("@/assets/images/beenvoice-logo.png")
}
style={{ width, height }}
contentFit="contain"
/>
</View>
);
}
/** Square app icon mark — fixed aspect ratio so flex parents cannot squash it. */
export function LogoMark({
size = 32,
style,
}: {
size?: number;
style?: ImageStyle;
}) {
const flat = StyleSheet.flatten(style);
const width =
typeof flat?.width === "number"
? flat.width
: typeof flat?.height === "number"
? flat.height
: size;
const height = typeof flat?.height === "number" ? flat.height : width;
return (
<View style={[styles.markBox, { width, height }]}>
<Image
source={require("@/assets/images/icon.png")}
style={styles.markImage}
contentFit="contain"
accessibilityLabel="beenvoice"
/>
</View>
);
}
export function HeadingText({
children,
style,
}: {
children: React.ReactNode;
style?: object;
}) {
const { colors } = useAppTheme();
return (
<Text style={[styles.heading, { color: colors.foreground }, style]}>{children}</Text>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
},
noShrink: {
flexShrink: 0,
},
markBox: {
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
markImage: {
width: "100%",
height: "100%",
},
heading: {
fontFamily: fonts.heading,
},
});
+37
View File
@@ -0,0 +1,37 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts, spacing } from "@/constants/theme";
import { tabLayout } from "@/lib/tab-layout";
import { useAppTheme } from "@/contexts/ThemeContext";
import { useTopChromeHeight } from "@/lib/top-chrome-insets";
type PageHeaderProps = {
title: string;
subtitle: string;
};
/** Title block — transparent, scrolls under TopChromeBar blur. */
export function PageHeader({ title, subtitle }: PageHeaderProps) {
const { colors } = useAppTheme();
const topChromeHeight = useTopChromeHeight();
return (
<View style={[tabLayout.pageHeader, { paddingTop: topChromeHeight + spacing.md }]}>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text>
</View>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 28,
lineHeight: 32,
fontFamily: fonts.heading,
},
subtitle: {
fontSize: 14,
lineHeight: 18,
fontFamily: fonts.body,
},
});
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useState } from "react";
import {
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { isValidPin } from "@/lib/app-lock";
type PinPromptProps = {
visible: boolean;
title: string;
message: string;
confirmLabel?: string;
requireConfirmation?: boolean;
onCancel: () => void;
onSubmit: (pin: string) => void;
};
export function PinPrompt({
visible,
title,
message,
confirmLabel = "Continue",
requireConfirmation = false,
onCancel,
onSubmit,
}: PinPromptProps) {
const { colors } = useAppTheme();
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!visible) {
setPin("");
setConfirmPin("");
setError("");
}
}, [visible]);
function handleSubmit() {
if (!isValidPin(pin)) {
setError("PIN must be 46 digits");
return;
}
if (requireConfirmation && pin !== confirmPin) {
setError("PINs do not match");
return;
}
onSubmit(pin);
}
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Pressable style={styles.backdrop} onPress={onCancel}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.card, borderColor: colors.border }]}
onPress={(event) => event.stopPropagation()}
>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
<TextInput
value={pin}
onChangeText={(value) => {
setError("");
setPin(value.replace(/\D/g, "").slice(0, 6));
}}
keyboardType="number-pad"
secureTextEntry
maxLength={6}
placeholder="PIN"
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
]}
/>
{requireConfirmation ? (
<TextInput
value={confirmPin}
onChangeText={(value) => {
setError("");
setConfirmPin(value.replace(/\D/g, "").slice(0, 6));
}}
keyboardType="number-pad"
secureTextEntry
maxLength={6}
placeholder="Confirm PIN"
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
]}
/>
) : null}
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
<View style={styles.actions}>
<Button title="Cancel" variant="secondary" onPress={onCancel} />
<Button title={confirmLabel} onPress={handleSubmit} />
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
backgroundColor: "rgba(0,0,0,0.35)",
},
sheet: {
borderWidth: 1,
borderRadius: 16,
padding: spacing.lg,
gap: spacing.md,
},
title: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
},
message: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
input: {
borderWidth: 1,
borderRadius: 12,
minHeight: 48,
paddingHorizontal: spacing.md,
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
letterSpacing: 6,
},
error: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
actions: {
flexDirection: "row",
gap: spacing.sm,
},
});
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { StyleSheet, type ViewStyle } from "react-native";
import { SafeAreaView, type Edge } from "react-native-safe-area-context";
type ScreenProps = {
children: ReactNode;
style?: ViewStyle;
/**
* Safe area edges to pad. Default: top + sides (Dynamic Island / notch).
* Tab screens usually omit bottom — the tab bar handles home-indicator spacing.
*/
edges?: Edge[];
};
/** Full-screen wrapper that respects Dynamic Island, notch, and side insets. */
export function Screen({ children, style, edges = ["top", "left", "right"] }: ScreenProps) {
return (
<SafeAreaView style={[styles.screen, style]} edges={edges}>
{children}
</SafeAreaView>
);
}
/** Auth / modal screens that aren't inside a tab bar. */
export function FullScreen({ children, style }: Omit<ScreenProps, "edges">) {
return (
<SafeAreaView style={[styles.screen, style]} edges={["top", "bottom", "left", "right"]}>
{children}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: "transparent",
},
});
+46
View File
@@ -0,0 +1,46 @@
import { StyleSheet, Text } from "react-native";
import { Card } from "@/components/ui/Card";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, spacing } from "@/constants/theme";
type StatCardProps = {
label: string;
value: string;
hint?: string;
};
/** Web `StatsCard` — glass card, border-0, shadow-md, p-6 */
export function StatCard({ label, value, hint }: StatCardProps) {
const { colors } = useAppTheme();
return (
<Card variant="stat" style={styles.card}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[styles.value, { color: colors.foreground }]}>{value}</Text>
{hint ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
) : null}
</Card>
);
}
const styles = StyleSheet.create({
card: {
flex: 1,
minWidth: "46%",
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
value: {
fontSize: 22,
fontFamily: fonts.heading,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
marginTop: 2,
},
});
+33
View File
@@ -0,0 +1,33 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { getStatusColor, statusLabels, type InvoiceStatus } from "@/lib/invoice-status";
export function StatusBadge({ status }: { status: InvoiceStatus }) {
const { isDark } = useAppTheme();
const color = getStatusColor(status, isDark);
return (
<View style={[styles.badge, { backgroundColor: `${color}22` }]}>
<Text style={[styles.text, { color }]}>{statusLabels[status]}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
height: 22,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
borderRadius: radii.pill,
},
text: {
fontSize: 10,
fontFamily: fonts.bodyBold,
textTransform: "uppercase",
letterSpacing: 0.4,
includeFontPadding: false,
},
});
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { StyleSheet, View } from "react-native";
import { StatusBar } from "expo-status-bar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TopChromeBar } from "@/components/TopChromeBar";
import { useAppTheme } from "@/contexts/ThemeContext";
type TabPageProps = {
children: ReactNode;
};
/** Tab root — floating blurred top chrome; children should be a TabScrollView. */
export function TabPage({ children }: TabPageProps) {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
return (
<View style={styles.root}>
<StatusBar style={isDark ? "light" : "dark"} />
<View
style={[
styles.content,
{ paddingLeft: insets.left, paddingRight: insets.right },
]}
>
{children}
</View>
<TopChromeBar />
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "transparent",
},
content: {
flex: 1,
backgroundColor: "transparent",
},
});
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native";
import { tabLayout } from "@/lib/tab-layout";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
type TabScrollViewProps = ScrollViewProps & {
/** Rendered above screen body — scrolls under the blurred top chrome. */
header?: ReactNode;
children: ReactNode;
};
/** Scroll view for native tab screens — content scrolls under the tab bar. */
export function TabScrollView({
header,
children,
contentContainerStyle,
style,
...props
}: TabScrollViewProps) {
const scrollPadding = useTabBarScrollPadding();
return (
<ScrollView
style={[styles.scroll, style]}
contentContainerStyle={[
tabLayout.scrollContent,
{ paddingBottom: scrollPadding },
contentContainerStyle,
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
{...props}
>
{header}
<View style={tabLayout.scrollBody}>{children}</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
backgroundColor: "transparent",
},
});
+29
View File
@@ -0,0 +1,29 @@
import { StyleSheet, View } from "react-native";
import { ClockedInIndicator } from "@/components/ClockedInIndicator";
import { Logo } from "@/components/Logo";
import { spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
/** Wordmark left, clocked-in indicator right — sits on TopChromeBar blur. */
export function TopChrome() {
const { isDark } = useAppTheme();
return (
<View style={styles.row}>
<Logo size="xs" onDark={isDark} />
<ClockedInIndicator />
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: TOP_CHROME_ROW_HEIGHT,
paddingHorizontal: spacing.md,
},
});
+55
View File
@@ -0,0 +1,55 @@
import { BlurView } from "expo-blur";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TopChrome } from "@/components/TopChrome";
import { useAppTheme } from "@/contexts/ThemeContext";
import { blurIntensity } from "@/lib/beenvoice-theme";
import {
TOP_CHROME_PADDING_BOTTOM,
TOP_CHROME_ROW_HEIGHT,
} from "@/lib/top-chrome-insets";
/** Blurred status-bar chrome with logo + clocked-in indicator. */
export function TopChromeBar() {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
const tint = isDark ? "rgba(9, 9, 11, 0.28)" : "rgba(255, 255, 255, 0.32)";
return (
<View
style={[
styles.host,
{
paddingTop: insets.top,
paddingBottom: TOP_CHROME_PADDING_BOTTOM,
paddingLeft: insets.left,
paddingRight: insets.right,
height: insets.top + TOP_CHROME_ROW_HEIGHT + TOP_CHROME_PADDING_BOTTOM,
},
]}
>
<BlurView
intensity={blurIntensity.chrome}
tint={isDark ? "dark" : "light"}
style={StyleSheet.absoluteFill}
/>
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: tint }]}
/>
<TopChrome />
</View>
);
}
const styles = StyleSheet.create({
host: {
overflow: "hidden",
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
});
+190
View File
@@ -0,0 +1,190 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
export type EditableLineItem = {
id?: string;
date: Date;
description: string;
hours: string;
rate: string;
};
type LineItemEditorProps = {
item: EditableLineItem;
currency: string;
expanded: boolean;
onToggle: () => void;
onChange: (patch: Partial<EditableLineItem>) => void;
onRemove: () => void;
};
export function LineItemEditor({
item,
currency,
expanded,
onToggle,
onChange,
onRemove,
}: LineItemEditorProps) {
const { colors } = useAppTheme();
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
const amount = hours * rate;
const borderStyle = { borderTopColor: colors.border };
if (!expanded) {
return (
<Pressable
accessibilityRole="button"
onPress={onToggle}
style={({ pressed }) => [styles.row, borderStyle, pressed && styles.rowPressed]}
>
<View style={styles.rowMain}>
<Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}>
{item.description.trim() || "Untitled line"}
</Text>
<Text style={[styles.rowSub, { color: colors.mutedForeground }]}>
{formatDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
</Text>
</View>
<Text style={[styles.rowAmount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
</Pressable>
);
}
return (
<View style={[styles.expanded, borderStyle]}>
<View style={styles.expandedHeader}>
<Text style={[styles.expandedLabel, { color: colors.mutedForeground }]}>Line item</Text>
<Pressable accessibilityRole="button" onPress={onToggle} hitSlop={8}>
<Ionicons name="chevron-up" size={18} color={colors.mutedForeground} />
</Pressable>
</View>
<Input
label="Description"
value={item.description}
onChangeText={(description) => onChange({ description })}
placeholder="What was done"
/>
<View style={styles.inlineRow}>
<View style={styles.inlineField}>
<Input
label="Hours"
value={item.hours}
onChangeText={(hours) => onChange({ hours })}
keyboardType="decimal-pad"
placeholder="0"
/>
</View>
<View style={styles.inlineField}>
<Input
label="Rate"
value={item.rate}
onChangeText={(rate) => onChange({ rate })}
keyboardType="decimal-pad"
placeholder="0"
/>
</View>
</View>
<DateTimeField
label="Date"
mode="date"
value={item.date}
onChange={(date) => onChange({ date })}
/>
<View style={styles.expandedFooter}>
<Text style={[styles.lineTotal, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
<Pressable accessibilityRole="button" onPress={onRemove} style={styles.removeButton}>
<Ionicons name="trash-outline" size={16} color={colors.destructive} />
<Text style={[styles.removeLabel, { color: colors.destructive }]}>Remove</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingVertical: spacing.sm,
borderTopWidth: 1,
},
rowPressed: {
opacity: 0.9,
},
rowMain: {
flex: 1,
gap: 2,
},
rowTitle: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
rowSub: {
fontFamily: fonts.body,
fontSize: 12,
},
rowAmount: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
expanded: {
gap: spacing.sm,
paddingVertical: spacing.sm,
borderTopWidth: 1,
},
expandedHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
expandedLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 13,
textTransform: "uppercase",
letterSpacing: 0.3,
},
inlineRow: {
flexDirection: "row",
gap: spacing.md,
},
inlineField: {
flex: 1,
},
expandedFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
lineTotal: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
removeButton: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingVertical: spacing.xs,
},
removeLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
});
+523
View File
@@ -0,0 +1,523 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Alert, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import { router } from "expo-router";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { tabLayout } from "@/lib/tab-layout";
import { formatDateTime } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { describeClockOutOutcome, formatElapsedSeconds } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
/** Hides the in-panel title card when idle (tab screen already has PageHeader). */
compact?: boolean;
header?: ReactNode;
};
export function TimeClockPanel({
defaultClientId = "",
defaultInvoiceId = "",
compact = false,
header,
}: TimeClockPanelProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createTimeClockStyles);
const utils = api.useUtils();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const clientsQuery = api.clients.getAll.useQuery();
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
const clients = clientsQuery.data ?? [];
const activeClientId = running?.clientId ?? clientId;
const billableQuery = api.invoices.getBillable.useQuery(
activeClientId ? { clientId: activeClientId } : undefined,
);
const billableInvoices = billableQuery.data ?? [];
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart });
const scrollPadding = useTabBarScrollPadding();
const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => {
await utils.timeEntries.getRunning.invalidate();
},
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.invoices.getBillable.invalidate(),
]);
},
onError: (err) => {
Alert.alert("Could not update timer", err.message);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
const message = describeClockOutOutcome({
outcome: data.outcome,
hours: data.hours,
rate: data.rate,
invoice: data.invoice,
});
Alert.alert(
data.outcome === "linked_to_invoice" ? "Time logged" : "Timer stopped",
message,
);
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
},
});
useEffect(() => {
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description);
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
useEffect(() => {
if (running || !clientId || rateText) return;
const client = clients.find((c) => c.id === clientId);
if (client?.defaultHourlyRate) {
setRateText(String(client.defaultHourlyRate));
}
}, [clientId, clients, rateText, running]);
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity(
{ ...running, description },
seconds,
);
};
sync();
const interval = setInterval(sync, 30_000);
return () => clearInterval(interval);
}, [running, description]);
const rate = parseFloat(rateText) || 0;
const displayRate = running ? (running.rate ?? 0) : rate;
const clientOptions = useMemo(
() => clients.map((client) => ({ label: client.name, value: client.id })),
[clients],
);
const invoiceOptions = useMemo(
() => [
{ label: "No invoice — save entry only", value: "" },
...billableInvoices.map((invoice) => ({
label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber} (${invoice.status})`,
value: invoice.id,
})),
],
[billableInvoices],
);
async function handleClockIn() {
try {
const backdated =
Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined;
await clockIn.mutateAsync({
description: description.trim(),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: rate || undefined,
startedAt: backdated,
});
setStartedAt(new Date());
} catch (err) {
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleClockOut() {
try {
await clockOut.mutateAsync({ description: description.trim() || undefined });
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleRunningClientChange(nextClientId: string) {
if (!running) return;
setClientId(nextClientId);
setInvoiceId("");
try {
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
const client = clients.find((c) => c.id === nextClientId);
if (client?.defaultHourlyRate != null) {
setRateText(String(client.defaultHourlyRate));
}
} catch {
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
}
}
async function handleRunningInvoiceChange(nextInvoiceId: string) {
if (!running) return;
const previous = invoiceId;
setInvoiceId(nextInvoiceId);
try {
await updateRunning.mutateAsync({ invoiceId: nextInvoiceId });
} catch {
setInvoiceId(previous);
}
}
if (runningQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading time clock…" />;
}
const todayEntries = (todayQuery.data ?? []).filter((entry) => entry.endedAt);
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null,
displayRate ? `$${displayRate}/hr` : null,
]
.filter(Boolean)
.join(" · ");
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={[tabLayout.scrollContent, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
refreshControl={
<RefreshControl
refreshing={runningQuery.isRefetching}
onRefresh={() => {
void runningQuery.refetch();
void clientsQuery.refetch();
void billableQuery.refetch();
void todayQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{header}
<View style={tabLayout.scrollBody}>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabel}>Running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningTitle}>
{description.trim() || "No description"}
</Text>
<Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)}
{runningMeta ? ` · ${runningMeta}` : ""}
</Text>
</>
) : (
<Text style={styles.idleHint}>Track billable time and link it to invoices.</Text>
)}
</View>
</GlassSurface>
) : null}
{running ? (
<Card style={styles.formCard}>
<View style={styles.formFields}>
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
disabled={updateRunning.isPending}
onValueChange={(next) => void handleRunningClientChange(next)}
/>
<SelectField
label="Invoice"
placeholder="No invoice — save entry only"
value={invoiceId}
options={invoiceOptions}
disabled={updateRunning.isPending}
onValueChange={(next) => void handleRunningInvoiceChange(next)}
/>
<Input
label="Description"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
/>
</View>
</Card>
) : (
<Card style={styles.formCard}>
<View style={styles.formFields}>
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
onValueChange={(next) => {
setClientId(next);
setInvoiceId("");
const client = clients.find((c) => c.id === next);
setRateText(
client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
);
}}
/>
<SelectField
label="Invoice"
placeholder="No invoice — save entry only"
value={invoiceId}
options={invoiceOptions}
disabled={!clientId}
onValueChange={setInvoiceId}
/>
<Input
label="Description"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
/>
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder="0.00"
/>
<DateTimeField
label="Started at"
value={startedAt}
maximumDate={new Date()}
onChange={setStartedAt}
/>
<Text style={styles.startedHint}>
Set an earlier time if you forgot to clock in when you started working.
</Text>
</View>
</Card>
)}
{running ? (
<Button
title="Clock out"
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
) : (
<Button title="Clock in" loading={clockIn.isPending} onPress={handleClockIn} />
)}
{todayEntries.length > 0 ? (
<Card title="Today">
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{entry.description || "No description"}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
</Text>
</View>
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
</>
);
if (!entry.invoice) {
return (
<View key={entry.id} style={styles.entryRow}>
{row}
</View>
);
}
return (
<Pressable
key={entry.id}
accessibilityRole="button"
accessibilityLabel={`View invoice ${invoiceLabel}`}
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
>
{row}
</Pressable>
);
})}
</Card>
) : null}
</View>
</ScrollView>
);
}
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
},
hero: {
padding: spacing.md,
gap: spacing.sm,
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
},
heroLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.4,
},
timerValue: {
fontSize: 52,
lineHeight: 56,
fontFamily: fonts.mono,
color: colors.foreground,
fontVariant: ["tabular-nums"],
},
runningTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
runningMeta: {
fontSize: 13,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
idleHint: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
marginTop: spacing.xs,
},
startedHint: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 18,
marginTop: -spacing.xs,
},
formCard: {
gap: 0,
},
formFields: {
gap: spacing.md,
},
entryRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
entryRowPressed: {
opacity: 0.65,
},
entryMeta: {
flex: 1,
gap: 2,
},
entryTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
entrySub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
entryHours: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
});
+95
View File
@@ -0,0 +1,95 @@
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
type PressableProps,
type ViewStyle,
} from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
type ButtonProps = PressableProps & {
title: string;
loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle;
};
export function Button({
title,
loading,
variant = "primary",
disabled,
style,
...props
}: ButtonProps) {
const { colors } = useAppTheme();
const isDisabled = disabled || loading;
const variantStyles = {
primary: { backgroundColor: colors.primary },
secondary: {
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
},
danger: {
backgroundColor: colors.destructiveBg,
borderWidth: 1,
borderColor: colors.destructive,
},
ghost: { backgroundColor: "transparent" },
} as const;
const labelStyles = {
primary: { color: colors.primaryForeground },
secondary: { color: colors.foreground },
danger: { color: colors.destructive },
ghost: { color: colors.foreground },
} as const;
return (
<Pressable
accessibilityRole="button"
disabled={isDisabled}
style={({ pressed }) => [
styles.base,
variantStyles[variant],
pressed && !isDisabled && styles.pressed,
isDisabled && styles.disabled,
style,
]}
{...props}
>
{loading ? (
<ActivityIndicator
color={variant === "primary" ? colors.primaryForeground : colors.primary}
/>
) : (
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
minHeight: 40,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.md,
},
pressed: {
opacity: 0.92,
},
disabled: {
opacity: 0.55,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
});
+37
View File
@@ -0,0 +1,37 @@
import { StyleSheet, Text, View, type StyleProp, type ViewProps, type ViewStyle } from "react-native";
import { GlassSurface } from "@/components/GlassSurface";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, spacing } from "@/constants/theme";
import { radius } from "@/lib/beenvoice-theme";
type CardProps = ViewProps & {
title?: string;
style?: StyleProp<ViewStyle>;
variant?: "card" | "stat";
};
export function Card({ title, style, children, variant = "card", ...props }: CardProps) {
const { colors } = useAppTheme();
return (
<GlassSurface style={StyleSheet.flatten(style)} radius={radius.lg} variant={variant}>
<View style={styles.inner} {...props}>
{title ? <Text style={[styles.title, { color: colors.foreground }]}>{title}</Text> : null}
{children}
</View>
</GlassSurface>
);
}
const styles = StyleSheet.create({
inner: {
paddingHorizontal: 20,
paddingVertical: spacing.md,
gap: spacing.sm,
},
title: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
});
+180
View File
@@ -0,0 +1,180 @@
import { Ionicons } from "@expo/vector-icons";
import DateTimePicker, {
type DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { useState } from "react";
import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatDate, formatDateTime } from "@/lib/format";
type DateTimeFieldProps = {
label: string;
value: Date;
mode?: "date" | "datetime";
maximumDate?: Date;
minimumDate?: Date;
onChange: (date: Date) => void;
};
export function DateTimeField({
label,
value,
mode = "datetime",
maximumDate = new Date(),
minimumDate,
onChange,
}: DateTimeFieldProps) {
const { colors, isDark } = useAppTheme();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
function openPicker() {
setDraft(value);
setOpen(true);
}
function applyDate(next: Date) {
const clamped =
next.getTime() > maximumDate.getTime()
? maximumDate
: minimumDate && next.getTime() < minimumDate.getTime()
? minimumDate
: next;
onChange(clamped);
}
function handleChange(event: DateTimePickerEvent, selected?: Date) {
if (Platform.OS === "android") {
setOpen(false);
if (event.type === "set" && selected) {
applyDate(selected);
}
return;
}
if (selected) {
setDraft(selected);
}
}
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Pressable
accessibilityRole="button"
onPress={openPicker}
style={({ pressed }) => [
styles.trigger,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
pressed && styles.triggerPressed,
]}
>
<Text style={[styles.value, { color: colors.foreground }]}>
{mode === "date" ? formatDate(value) : formatDateTime(value)}
</Text>
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} />
</Pressable>
{Platform.OS === "ios" ? (
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.card }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Pressable onPress={() => setOpen(false)}>
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text>
</Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
<Pressable
onPress={() => {
applyDate(draft);
setOpen(false);
}}
>
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<DateTimePicker
value={draft}
mode={mode}
display="spinner"
maximumDate={maximumDate}
minimumDate={minimumDate}
themeVariant={isDark ? "dark" : "light"}
onChange={handleChange}
/>
</Pressable>
</Pressable>
</Modal>
) : open ? (
<DateTimePicker
value={draft}
mode={mode}
maximumDate={maximumDate}
minimumDate={minimumDate}
onChange={handleChange}
/>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.xs,
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
minHeight: 48,
paddingVertical: spacing.sm,
},
triggerPressed: {
opacity: 0.92,
},
value: {
fontSize: 15,
fontFamily: fonts.body,
flex: 1,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.lg,
borderTopRightRadius: radii.lg,
paddingBottom: spacing.lg,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
},
sheetTitle: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
sheetAction: {
fontSize: 15,
fontFamily: fonts.bodyMedium,
},
});
+62
View File
@@ -0,0 +1,62 @@
import {
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
type InputProps = TextInputProps & {
label: string;
error?: string;
};
export function Input({ label, error, style, ...props }: InputProps) {
const { colors } = useAppTheme();
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<TextInput
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
{
borderColor: colors.border,
color: colors.foreground,
backgroundColor: colors.cardGlass,
},
error && { borderColor: colors.destructive },
style,
]}
{...props}
/>
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
input: {
minHeight: 40,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: fonts.body,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
});
+204
View File
@@ -0,0 +1,204 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export type SelectOption = {
label: string;
value: string;
};
type SelectFieldProps = {
label: string;
placeholder: string;
value: string;
options: SelectOption[];
disabled?: boolean;
onValueChange: (value: string) => void;
};
export function SelectField({
label,
placeholder,
value,
options,
disabled,
onValueChange,
}: SelectFieldProps) {
const { colors } = useAppTheme();
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={() => setOpen(true)}
style={({ pressed }) => [
styles.trigger,
{
borderColor: colors.borderGlass,
backgroundColor: colors.cardGlass,
},
disabled && styles.triggerDisabled,
pressed && !disabled && styles.triggerPressed,
]}
>
<Text
style={[
styles.triggerText,
{ color: colors.foreground },
!selected && { color: colors.mutedForeground },
]}
numberOfLines={1}
>
{selected?.label ?? placeholder}
</Text>
<Ionicons name="chevron-down" size={18} color={colors.mutedForeground} />
</Pressable>
<Modal
animationType="slide"
onRequestClose={() => setOpen(false)}
transparent
visible={open}
>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.background }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{options.map((option) => {
const isSelected = option.value === value;
return (
<Pressable
key={option.value || "__empty__"}
accessibilityRole="button"
onPress={() => {
onValueChange(option.value);
setOpen(false);
}}
style={({ pressed }) => [
styles.option,
isSelected && { backgroundColor: colors.muted },
pressed && styles.optionPressed,
]}
>
<Text
style={[
styles.optionText,
{ color: colors.foreground },
isSelected && styles.optionTextSelected,
]}
numberOfLines={2}
>
{option.label}
</Text>
{isSelected ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
trigger: {
minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
triggerDisabled: {
opacity: 0.55,
},
triggerPressed: {
opacity: 0.92,
},
triggerText: {
flex: 1,
fontSize: 14,
fontFamily: fonts.body,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.45)",
},
sheet: {
maxHeight: "70%",
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
paddingBottom: spacing.lg,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sheetTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
},
done: {
fontSize: 15,
fontFamily: fonts.bodyMedium,
},
option: {
minHeight: 48,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
optionPressed: {
opacity: 0.9,
},
optionText: {
flex: 1,
fontSize: 15,
fontFamily: fonts.body,
},
optionTextSelected: {
fontFamily: fonts.bodySemiBold,
},
});