/** * CustomBottomSheet — replaces ALL system Alert/Modal patterns. * * Built on @gorhom/bottom-sheet with MD3 styling, drag-handle, * scrim overlay, and smooth reanimated transitions. * * Usage: * * * * * Imperative API: * sheetRef.current?.present() – open * sheetRef.current?.dismiss() – close */ import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Pressable, } from 'react-native'; import BottomSheet, { BottomSheetBackdrop, BottomSheetScrollView, BottomSheetView, type BottomSheetBackdropProps, } from '@gorhom/bottom-sheet'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import {useTheme} from '../theme'; import type {MD3Theme} from '../theme'; const hapticOptions = {enableVibrateFallback: true, ignoreAndroidSystemSettings: false}; export const triggerHaptic = (type: 'impactLight' | 'impactMedium' | 'selection' | 'notificationSuccess' = 'impactLight') => ReactNativeHapticFeedback.trigger(type, hapticOptions); // ─── Public Handle ─────────────────────────────────────────────────── export interface CustomBottomSheetHandle { present: () => void; dismiss: () => void; } // ─── Props ─────────────────────────────────────────────────────────── export interface CustomBottomSheetProps { /** Title displayed in the sheet header */ title?: string; /** Snap-point percentages or absolute values */ snapPoints?: (string | number)[]; /** Callback when the sheet is fully closed */ onDismiss?: () => void; /** Whether to wrap children in a ScrollView (default: true) */ scrollable?: boolean; /** Header left action (e.g. Cancel) */ headerLeft?: {label: string; onPress: () => void}; /** Header right action (e.g. Save) */ headerRight?: {label: string; onPress: () => void; color?: string}; /** Content */ children: React.ReactNode; /** Enable dynamic sizing instead of snapPoints */ enableDynamicSizing?: boolean; } // ─── Component ─────────────────────────────────────────────────────── const CustomBottomSheetInner = forwardRef( ( { title, snapPoints: snapPointsProp, onDismiss, scrollable = true, headerLeft, headerRight, children, enableDynamicSizing = false, }, ref, ) => { const theme = useTheme(); const s = makeStyles(theme); const sheetRef = useRef(null); const snapPoints = useMemo(() => snapPointsProp ?? ['60%'], [snapPointsProp]); // Imperative handle useImperativeHandle(ref, () => ({ present: () => { triggerHaptic('impactMedium'); sheetRef.current?.snapToIndex(0); }, dismiss: () => { triggerHaptic('impactLight'); sheetRef.current?.close(); }, })); // Backdrop with scrim const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( ), [], ); // Handle indicator const renderHandle = useCallback( () => ( ), [s], ); const Wrapper = scrollable ? BottomSheetScrollView : BottomSheetView; return ( {/* Sheet Header */} {(title || headerLeft || headerRight) && ( {headerLeft ? ( {headerLeft.label} ) : ( )} {title ? {title} : } {headerRight ? ( {headerRight.label} ) : ( )} )} {/* Sheet Body */} {children} ); }, ); CustomBottomSheetInner.displayName = 'CustomBottomSheet'; export const CustomBottomSheet = CustomBottomSheetInner; // ─── Styles ────────────────────────────────────────────────────────── function makeStyles(theme: MD3Theme) { const {colors, typography, shape, spacing} = theme; return StyleSheet.create({ container: { zIndex: 999, }, background: { backgroundColor: colors.surfaceContainerLow, borderTopLeftRadius: shape.extraLarge, borderTopRightRadius: shape.extraLarge, }, handleContainer: { alignItems: 'center', paddingTop: spacing.sm, paddingBottom: spacing.xs, }, handle: { width: 32, height: 4, borderRadius: 2, backgroundColor: colors.onSurfaceVariant, opacity: 0.4, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.xl, paddingTop: spacing.sm, paddingBottom: spacing.md, borderBottomWidth: 1, borderBottomColor: colors.outlineVariant, }, headerTitle: { ...typography.titleMedium, color: colors.onSurface, fontWeight: '600', }, headerLeftText: { ...typography.labelLarge, color: colors.onSurfaceVariant, }, headerRightText: { ...typography.labelLarge, color: colors.primary, fontWeight: '600', }, headerPlaceholder: { width: 48, }, body: { flex: 1, }, bodyContent: { paddingHorizontal: spacing.xl, paddingTop: spacing.lg, paddingBottom: spacing.xxxl + 20, }, }); } // ─── Convenience Sub-Components ────────────────────────────────────── /** * MD3-styled text input for use inside bottom sheets. */ export interface BottomSheetInputProps { label: string; value: string; onChangeText: (text: string) => void; placeholder?: string; keyboardType?: 'default' | 'decimal-pad' | 'number-pad' | 'email-address'; error?: string; multiline?: boolean; prefix?: string; autoFocus?: boolean; } import {TextInput} from 'react-native'; export const BottomSheetInput: React.FC = ({ label, value, onChangeText, placeholder, keyboardType = 'default', error, multiline = false, prefix, autoFocus = false, }) => { const theme = useTheme(); const {colors, typography, shape, spacing} = theme; const [focused, setFocused] = React.useState(false); const borderColor = error ? colors.error : focused ? colors.primary : colors.outline; return ( {label} {prefix && ( {prefix} )} setFocused(true)} onBlur={() => setFocused(false)} /> {error && ( {error} )} ); }; /** * MD3 chip selector row for use inside bottom sheets. */ export interface BottomSheetChipSelectorProps { label?: string; options: {value: T; label: string; icon?: string}[]; selected: T; onSelect: (value: T) => void; } import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; export function BottomSheetChipSelector({ label, options, selected, onSelect, }: BottomSheetChipSelectorProps) { const theme = useTheme(); const {colors, typography, shape, spacing} = theme; return ( {label && ( {label} )} {options.map(opt => { const isSelected = opt.value === selected; return ( { triggerHaptic('selection'); onSelect(opt.value); }} style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderRadius: shape.small, borderWidth: 1, borderColor: isSelected ? colors.primary : colors.outlineVariant, backgroundColor: isSelected ? colors.primaryContainer : colors.surfaceContainerLowest, }}> {opt.icon && ( )} {opt.label} ); })} ); }