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:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user