32ffe782ea
Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens. Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
5.7 KiB
TypeScript
219 lines
5.7 KiB
TypeScript
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;
|
|
required?: boolean;
|
|
error?: string;
|
|
onValueChange: (value: string) => void;
|
|
};
|
|
|
|
export function SelectField({
|
|
label,
|
|
placeholder,
|
|
value,
|
|
options,
|
|
disabled,
|
|
required,
|
|
error,
|
|
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}
|
|
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
|
</Text>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={disabled}
|
|
onPress={() => setOpen(true)}
|
|
style={({ pressed }) => [
|
|
styles.trigger,
|
|
{
|
|
borderColor: error ? colors.destructive : 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>
|
|
{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,
|
|
},
|
|
error: {
|
|
fontSize: 13,
|
|
fontFamily: fonts.body,
|
|
},
|
|
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,
|
|
},
|
|
});
|