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
+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,
},
});