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