diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index d0851e9..872887b 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,7 +1,33 @@ import { Ionicons } from '@expo/vector-icons'; import { FlashList } from '@shopify/flash-list'; -import { useState } from 'react'; -import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import type { ReactNode } from 'react'; +import { useRef, useState } from 'react'; +import { + Keyboard, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + useWindowDimensions, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + Easing, + FadeIn, + FadeOut, + LinearTransition, + runOnJS, + SlideInDown, + SlideOutDown, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { TrailCard, difficultyLabel } from '@/src/components/TrailCard'; import { regions, trails, type Difficulty, type Trail } from '@/src/data/trails'; @@ -16,6 +42,11 @@ const SORTS = [ { key: 'rating', label: 'Rating' }, ] as const; +const CONTROL_TRANSITION = LinearTransition.duration(280).easing(Easing.out(Easing.cubic)); +const SHEET_IN = SlideInDown.duration(300).easing(Easing.out(Easing.cubic)); +const SHEET_OUT = SlideOutDown.duration(220).easing(Easing.in(Easing.cubic)); +const MAX_CONTROL_FONT_SCALE = 1.5; + type SortKey = (typeof SORTS)[number]['key']; interface TrailRow { @@ -29,6 +60,11 @@ export default function TrailsScreen() { const [region, setRegion] = useState(null); const [difficulty, setDifficulty] = useState(null); const [sort, setSort] = useState('name'); + const [searchOpen, setSearchOpen] = useState(false); + const [sortSheetOpen, setSortSheetOpen] = useState(false); + const [filterSheetOpen, setFilterSheetOpen] = useState(false); + const searchInputRef = useRef(null); + const preserveSearchOnBlurRef = useRef(false); const needle = query.trim().toLowerCase(); @@ -55,6 +91,7 @@ export default function TrailsScreen() { }); const filtered = needle.length > 0 || region !== null || difficulty !== null; + const sortLabel = SORTS.find((option) => option.key === sort)?.label ?? 'Name'; const clearFilters = () => { setQuery(''); @@ -62,91 +99,160 @@ export default function TrailsScreen() { setDifficulty(null); }; + const closeSearch = () => { + if (query.length > 0) { + setQuery(''); + return; + } + + Keyboard.dismiss(); + setSearchOpen(false); + }; + + const collapseSearch = () => { + if (preserveSearchOnBlurRef.current) return; + + setQuery(''); + setSearchOpen(false); + }; + + const openFilters = () => { + preserveSearchOnBlurRef.current = true; + Keyboard.dismiss(); + setFilterSheetOpen(true); + }; + + const closeFilters = () => { + setFilterSheetOpen(false); + + requestAnimationFrame(() => { + preserveSearchOnBlurRef.current = false; + if (searchOpen) searchInputRef.current?.focus(); + }); + }; + return ( - - - - {query.length > 0 ? ( - setQuery('')} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel="Clear search" + + {!searchOpen ? ( + - - + setSortSheetOpen(true)} + accessibilityRole="button" + accessibilityLabel={`Sort by ${sortLabel}`} + accessibilityState={{ expanded: sortSheetOpen }} + style={({ pressed }) => [styles.sortControl, pressed && styles.pressed]} + > + + Sort By{' '} + {sortLabel} + + + + ) : null} - - - setRegion(null)} /> - {regions.map((name) => ( - setRegion(region === name ? null : name)} - /> - ))} - - - - setDifficulty(null)} /> - {DIFFICULTIES.map((level) => ( - setDifficulty(difficulty === level ? null : level)} - /> - ))} - - - - - {visible.length} {visible.length === 1 ? 'trail' : 'trails'} - - - {SORTS.map((option) => { - const active = sort === option.key; - return ( + {searchOpen ? ( + + + + setSort(option.key)} + onPress={closeSearch} + hitSlop={8} accessibilityRole="button" - accessibilityState={{ selected: active }} - accessibilityLabel={`Sort by ${option.label.toLowerCase()}`} - style={[styles.segmentItem, active && { backgroundColor: colors.surface }]} + accessibilityLabel={query.length > 0 ? 'Clear search' : 'Close search'} + style={({ pressed }) => pressed && styles.pressed} > - - {option.label} - + - ); - })} - + + + ) : ( + + setSearchOpen(true)} + accessibilityRole="button" + accessibilityLabel="Open trail search" + style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]} + > + + + + )} + + {searchOpen ? ( + + { + preserveSearchOnBlurRef.current = true; + }} + onPress={openFilters} + accessibilityRole="button" + accessibilityLabel="Open trail filters" + accessibilityState={{ expanded: filterSheetOpen }} + style={({ pressed }) => [ + styles.filterControl, + { + backgroundColor: filterSheetOpen ? colors.primaryMuted : colors.surface, + borderColor: filterSheetOpen ? colors.primary : colors.border, + }, + pressed && styles.pressed, + ]} + > + + + + ) : null} @@ -164,23 +270,246 @@ export default function TrailsScreen() { No trails found - - Nothing matches the current search and filters. - + Nothing matches the current search and filters. {filtered ? ( - - Clear filters - + Clear filters ) : null} } /> + + {sortSheetOpen ? ( + setSortSheetOpen(false)}> + + {SORTS.map((option) => { + const selected = sort === option.key; + return ( + { + setSort(option.key); + setSortSheetOpen(false); + }} + accessibilityRole="button" + accessibilityState={{ selected }} + accessibilityLabel={`Sort by ${option.label.toLowerCase()}`} + style={({ pressed }) => [ + styles.choiceRow, + { backgroundColor: selected ? colors.primaryMuted : colors.surface }, + pressed && styles.pressed, + ]} + > + + {option.label} + + {selected ? : null} + + ); + })} + + + ) : null} + + {filterSheetOpen ? ( + + + + setRegion(null)} /> + {regions.map((name) => ( + setRegion(region === name ? null : name)} + /> + ))} + + + + setDifficulty(null)} /> + {DIFFICULTIES.map((level) => ( + setDifficulty(difficulty === level ? null : level)} + /> + ))} + + + + { + setRegion(null); + setDifficulty(null); + }} + accessibilityRole="button" + style={({ pressed }) => [ + styles.clearButton, + { borderColor: colors.border }, + pressed && styles.pressed, + ]} + > + + Clear + + + [ + styles.showButton, + { backgroundColor: colors.primary }, + pressed && styles.pressed, + ]} + > + + Show {visible.length} {visible.length === 1 ? 'trail' : 'trails'} + + + + + + ) : null} + + ); +} + +function BottomSheet({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: ReactNode; +}) { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { height: screenHeight } = useWindowDimensions(); + const dragOffset = useSharedValue(0); + + const dismissSheet = () => { + dragOffset.value = withTiming( + screenHeight, + { duration: 220, easing: Easing.in(Easing.cubic) }, + (finished) => { + if (finished) runOnJS(onClose)(); + }, + ); + }; + + const dragGesture = Gesture.Pan() + .activeOffsetY(6) + .onUpdate((event) => { + dragOffset.value = Math.max(0, event.translationY); + }) + .onEnd((event) => { + if (event.translationY > 80 || event.velocityY > 800) { + dragOffset.value = withTiming( + screenHeight, + { duration: 220, easing: Easing.in(Easing.cubic) }, + (finished) => { + if (finished) runOnJS(onClose)(); + }, + ); + return; + } + + dragOffset.value = withSpring(0, { damping: 22, stiffness: 240 }); + }); + + const dragStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: dragOffset.value }], + })); + + return ( + + + + + + + + + + + + + + + {title} + + + {children} + + + + + ); +} + +function FilterSection({ title, children }: { title: string; children: ReactNode }) { + const { colors } = useTheme(); + return ( + + + {title} + + {children} ); } @@ -200,15 +529,19 @@ function Chip({ onPress={onPress} accessibilityRole="button" accessibilityState={{ selected }} - style={[ + style={({ pressed }) => [ styles.chip, { - backgroundColor: selected ? colors.primary : colors.surface, + backgroundColor: selected ? colors.primary : colors.surfaceAlt, borderColor: selected ? colors.primary : colors.border, }, + pressed && styles.pressed, ]} > - + {label} @@ -217,48 +550,134 @@ function Chip({ const styles = StyleSheet.create({ screen: { flex: 1 }, - header: { paddingTop: spacing.md, gap: spacing.sm }, - searchBar: { + header: { paddingTop: spacing.md, paddingBottom: spacing.xs }, + controlRow: { + minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - marginHorizontal: spacing.lg, - paddingHorizontal: spacing.md, - height: 40, + paddingHorizontal: spacing.lg, + }, + sortControlSlot: { flex: 1, minWidth: 0 }, + sortControl: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: spacing.xs, + }, + sortControlLabel: { flexShrink: 1, fontSize: 15, fontWeight: '500' }, + sortControlValue: { fontWeight: '800' }, + searchShell: { + height: 44, borderRadius: radius.pill, borderWidth: StyleSheet.hairlineWidth, + overflow: 'hidden', }, - searchInput: { flex: 1, fontSize: 15, padding: 0 }, - chipRow: { gap: spacing.sm, paddingHorizontal: spacing.lg }, - chip: { + searchShellCollapsed: { width: 44, flexBasis: 44, flexGrow: 0, flexShrink: 0 }, + searchShellExpanded: { width: 'auto', flexBasis: 0, flexGrow: 1, flexShrink: 1, minWidth: 0 }, + searchContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, paddingHorizontal: spacing.md, - paddingVertical: 6, - borderRadius: radius.pill, - borderWidth: StyleSheet.hairlineWidth, }, - chipLabel: { fontSize: 13, fontWeight: '500' }, - sortRow: { + searchInput: { flex: 1, minWidth: 0, fontSize: 15, padding: 0 }, + iconButton: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + filterControlSlot: { width: 44, height: 44 }, + filterControl: { + flex: 1, flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingTop: spacing.xs, + justifyContent: 'center', + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, }, - resultCount: { fontSize: 13 }, - segment: { flexDirection: 'row', padding: 2, borderRadius: radius.pill }, - segmentItem: { paddingHorizontal: spacing.md, paddingVertical: 5, borderRadius: radius.pill }, - segmentLabel: { fontSize: 12, fontWeight: '600' }, listContent: { padding: spacing.lg }, separator: { height: spacing.md }, empty: { alignItems: 'center', paddingTop: spacing.xxl, gap: spacing.sm }, emptyTitle: { fontSize: 17, fontWeight: '600' }, emptyBody: { fontSize: 14, textAlign: 'center', paddingHorizontal: spacing.xl }, emptyAction: { + minHeight: 44, marginTop: spacing.sm, paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, + alignItems: 'center', + justifyContent: 'center', borderRadius: radius.pill, borderWidth: StyleSheet.hairlineWidth, }, emptyActionLabel: { fontSize: 14, fontWeight: '600' }, + pressed: { opacity: 0.68 }, + modalRoot: { flex: 1, justifyContent: 'flex-end' }, + sheetWrapper: { width: '100%' }, + sheet: { + maxHeight: '82%', + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + paddingHorizontal: spacing.lg, + shadowColor: '#000000', + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.16, + shadowRadius: 16, + elevation: 16, + }, + sheetHandle: { + width: 40, + height: 4, + borderRadius: radius.pill, + }, + sheetHandleTarget: { + minHeight: 28, + alignItems: 'center', + justifyContent: 'center', + }, + sheetHeader: { + minHeight: 56, + alignItems: 'center', + justifyContent: 'center', + }, + sheetTitle: { fontSize: 20, fontWeight: '800', textAlign: 'center' }, + choiceList: { gap: spacing.sm, paddingBottom: spacing.sm }, + choiceRow: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + borderRadius: radius.md, + }, + choiceLabel: { fontSize: 16, fontWeight: '700' }, + filterSheetContent: { gap: spacing.xl, paddingBottom: spacing.sm }, + filterSection: { gap: spacing.sm }, + filterSectionTitle: { fontSize: 15, fontWeight: '800' }, + chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + chip: { + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: spacing.md, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + }, + chipLabel: { fontSize: 13, fontWeight: '600' }, + filterActions: { flexDirection: 'row', gap: spacing.sm }, + clearButton: { + minHeight: 48, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.lg, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + }, + clearButtonLabel: { fontSize: 15, fontWeight: '700' }, + showButton: { + minHeight: 48, + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.lg, + borderRadius: radius.pill, + }, + showButtonLabel: { fontSize: 15, fontWeight: '800' }, });