Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type PressableProps,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
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;
|
||||
leftIcon?: keyof typeof Ionicons.glyphMap;
|
||||
showArrow?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
loading,
|
||||
variant = "primary",
|
||||
disabled,
|
||||
style,
|
||||
leftIcon,
|
||||
showArrow = false,
|
||||
...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: colors.cardGlass,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderGlass,
|
||||
},
|
||||
} 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}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{leftIcon ? (
|
||||
<Ionicons
|
||||
name={leftIcon}
|
||||
size={16}
|
||||
color={labelStyles[variant].color}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={[styles.label, labelStyles[variant]]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{showArrow ? (
|
||||
<Ionicons
|
||||
name="arrow-forward"
|
||||
size={16}
|
||||
color={labelStyles[variant].color}
|
||||
style={styles.arrow}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
minHeight: 44,
|
||||
borderRadius: radii.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
minWidth: 0,
|
||||
},
|
||||
arrow: {
|
||||
marginTop: 1,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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,
|
||||
alignItems: "stretch",
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import DateTimePicker, {
|
||||
type DateTimePickerEvent,
|
||||
} from "@react-native-community/datetimepicker";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
|
||||
import { fonts, radii } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatShortDate } from "@/lib/format";
|
||||
|
||||
type CompactDateFieldProps = {
|
||||
value: Date;
|
||||
onChange: (date: Date) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
maximumDate?: Date;
|
||||
minimumDate?: Date;
|
||||
};
|
||||
|
||||
export function CompactDateField({
|
||||
value,
|
||||
onChange,
|
||||
style,
|
||||
maximumDate = new Date(2100, 0, 1),
|
||||
minimumDate,
|
||||
}: CompactDateFieldProps) {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Change date"
|
||||
onPress={() => {
|
||||
setDraft(value);
|
||||
setOpen(true);
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.trigger,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
pressed && styles.pressed,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.value, { color: colors.foreground }]} numberOfLines={1}>
|
||||
{formatShortDate(value)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={12} 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.action, { color: colors.mutedForeground }]}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Date</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
applyDate(draft);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.action, { color: colors.primary }]}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={draft}
|
||||
mode="date"
|
||||
display="spinner"
|
||||
maximumDate={maximumDate}
|
||||
minimumDate={minimumDate}
|
||||
themeVariant={isDark ? "dark" : "light"}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
) : open ? (
|
||||
<DateTimePicker
|
||||
value={draft}
|
||||
mode="date"
|
||||
maximumDate={maximumDate}
|
||||
minimumDate={minimumDate}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
trigger: {
|
||||
minHeight: 36,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 8,
|
||||
gap: 2,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
value: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 12,
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.45)",
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: radii.lg,
|
||||
borderTopRightRadius: radii.lg,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
sheetTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
action: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, TextInput, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
import { fonts, radii } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type CompactStepperInputProps = {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
step?: number;
|
||||
min?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export function CompactStepperInput({
|
||||
value,
|
||||
onChangeText,
|
||||
step = 0.25,
|
||||
min = 0,
|
||||
style,
|
||||
}: CompactStepperInputProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
function adjust(delta: number) {
|
||||
const current = Number.parseFloat(value) || 0;
|
||||
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
|
||||
onChangeText(Number.isInteger(next) ? String(next) : String(next));
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.field,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Decrease hours"
|
||||
hitSlop={4}
|
||||
onPress={() => adjust(-step)}
|
||||
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Ionicons name="remove" size={14} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[styles.input, { color: colors.foreground }]}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Increase hours"
|
||||
hitSlop={4}
|
||||
onPress={() => adjust(step)}
|
||||
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Ionicons name="add" size={14} color={colors.foreground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
field: {
|
||||
minHeight: 36,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
stepButton: {
|
||||
width: 28,
|
||||
height: 36,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
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,
|
||||
alignSelf: "stretch",
|
||||
width: "100%",
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
alignSelf: "stretch",
|
||||
width: "100%",
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type TextInputProps,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
|
||||
type InputProps = TextInputProps & {
|
||||
label: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
leftIcon?: keyof typeof Ionicons.glyphMap;
|
||||
labelAccessory?: React.ReactNode;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
error,
|
||||
required,
|
||||
leftIcon,
|
||||
labelAccessory,
|
||||
hint,
|
||||
style,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||
{label}
|
||||
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||
</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
{leftIcon ? (
|
||||
<Ionicons
|
||||
name={leftIcon}
|
||||
size={16}
|
||||
color={colors.mutedForeground}
|
||||
style={styles.leftIcon}
|
||||
/>
|
||||
) : null}
|
||||
<TextInput
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.input,
|
||||
leftIcon && styles.inputWithIcon,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
color: colors.foreground,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
error && { borderColor: colors.destructive },
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
{hint && !error ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
|
||||
) : null}
|
||||
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
field: {
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
},
|
||||
leftIcon: {
|
||||
position: "absolute",
|
||||
left: spacing.md,
|
||||
zIndex: 1,
|
||||
},
|
||||
input: {
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
inputWithIcon: {
|
||||
paddingLeft: spacing.md + 24,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
error: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type StepperInputProps = Omit<TextInputProps, "value" | "onChangeText"> & {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
step?: number;
|
||||
min?: number;
|
||||
};
|
||||
|
||||
export function StepperInput({
|
||||
label,
|
||||
value,
|
||||
onChangeText,
|
||||
step = 0.25,
|
||||
min = 0,
|
||||
...props
|
||||
}: StepperInputProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
function adjust(delta: number) {
|
||||
const current = Number.parseFloat(value) || 0;
|
||||
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
|
||||
onChangeText(Number.isInteger(next) ? String(next) : String(next));
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.field,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Decrease ${label}`}
|
||||
hitSlop={6}
|
||||
onPress={() => adjust(-step)}
|
||||
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
|
||||
>
|
||||
<Ionicons name="remove" size={18} color={colors.foreground} />
|
||||
</Pressable>
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType="decimal-pad"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[styles.input, { color: colors.foreground }]}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Increase ${label}`}
|
||||
hitSlop={6}
|
||||
onPress={() => adjust(step)}
|
||||
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
|
||||
>
|
||||
<Ionicons name="add" size={18} color={colors.foreground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
field: {
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
stepButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: radii.sm,
|
||||
},
|
||||
stepPressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user