Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'react-native';
import { Image, Platform } from 'react-native';
import { useTheme } from '@/src/theme';

export default function TabsLayout() {
Expand All @@ -20,6 +20,7 @@ export default function TabsLayout() {
name="index"
options={{
title: 'Trails',
headerTitleAlign: Platform.OS === 'android' ? 'center' : undefined,
headerShadowVisible: false,
headerTitle: () => (
<Image
Expand Down
325 changes: 123 additions & 202 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,51 @@
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 { TrailCard, difficultyLabel } from '@/src/components/TrailCard';
import { regions, trails, type Difficulty, type Trail } from '@/src/data/trails';
import { routeDistanceMeters } from '@/src/lib/geo';
import { radius, spacing, useTheme } from '@/src/theme';

const DIFFICULTIES: Difficulty[] = ['easy', 'moderate', 'hard', 'expert'];
import { FlashList } from "@shopify/flash-list";
import { BlurTargetView } from "expo-blur";
import { useRef, useState } from "react";
import { Platform, View } from "react-native";
import Animated from "react-native-reanimated";

import { EmptyState } from "@/src/components/EmptyState";
import { FilterChip, FilterSection } from "@/src/components/FilterSheet";
import {
SEARCH_FILTER_CONTROLS_HEIGHT,
SearchFilterControls,
} from "@/src/components/SearchFilterControls";
import { TrailCard, difficultyLabel } from "@/src/components/TrailCard";
import { regions, trails, type Difficulty, type Trail } from "@/src/data/trails";
import { useScrollVisibility } from "@/src/hooks/useScrollVisibility";
import { routeDistanceMeters } from "@/src/lib/geo";
import { spacing } from "@/src/theme";

const DIFFICULTIES: Difficulty[] = ["easy", "moderate", "hard", "expert"];

const SORTS = [
{ key: 'name', label: 'Name' },
{ key: 'distance', label: 'Distance' },
{ key: 'rating', label: 'Rating' },
{ key: "name", label: "Name" },
{ key: "distance", label: "Distance" },
{ key: "rating", label: "Rating" },
] as const;

type SortKey = (typeof SORTS)[number]['key'];
type SortKey = (typeof SORTS)[number]["key"];

interface TrailRow {
trail: Trail;
distanceMeters: number;
}

const AnimatedFlashList = Animated.createAnimatedComponent(FlashList<TrailRow>);

export default function TrailsScreen() {
const { colors } = useTheme();
const [query, setQuery] = useState('');
const blurTargetRef = useRef<View>(null);
const [query, setQuery] = useState("");
const [region, setRegion] = useState<string | null>(null);
const [difficulty, setDifficulty] = useState<Difficulty | null>(null);
const [sort, setSort] = useState<SortKey>('name');
const [sort, setSort] = useState<SortKey>("name");
const {
onScroll: handleScroll,
translateY: controlsTranslateY,
interactive: controlsInteractive,
} = useScrollVisibility({
hiddenTranslateY: -(SEARCH_FILTER_CONTROLS_HEIGHT + spacing.sm),
});

const needle = query.trim().toLowerCase();

Expand All @@ -49,216 +66,120 @@ export default function TrailsScreen() {
);
})
.sort((a, b) => {
if (sort === 'distance') return a.distanceMeters - b.distanceMeters;
if (sort === 'rating') return b.trail.rating - a.trail.rating || b.trail.reviewCount - a.trail.reviewCount;
if (sort === "distance") return a.distanceMeters - b.distanceMeters;
if (sort === "rating")
return (
b.trail.rating - a.trail.rating ||
b.trail.reviewCount - a.trail.reviewCount
);
return a.trail.name.localeCompare(b.trail.name);
});

const filtered = needle.length > 0 || region !== null || difficulty !== null;

const clearFilters = () => {
setQuery('');
setRegion(null);
setDifficulty(null);
};

const trailList = (
<AnimatedFlashList
data={visible}
keyExtractor={(row) => row.trail.id}
renderItem={({ item }) => (
<TrailCard trail={item.trail} distanceMeters={item.distanceMeters} />
)}
contentContainerStyle={{
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
paddingTop: SEARCH_FILTER_CONTROLS_HEIGHT + spacing.lg,
}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onScroll={handleScroll}
scrollEventThrottle={16}
ItemSeparatorComponent={() => <View style={{ height: spacing.md }} />}
ListEmptyComponent={
<EmptyState
icon="trail-sign-outline"
title="No trails found"
description="Nothing matches the current search and filters."
action={
filtered
? {
label: "Clear filters",
accessibilityLabel: "Clear search and filters",
onPress: clearFilters,
}
: undefined
}
/>
}
/>
);

return (
<View style={[styles.screen, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<View style={[styles.searchBar, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="search" size={16} color={colors.textMuted} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search trails, regions, tags"
placeholderTextColor={colors.textMuted}
style={[styles.searchInput, { color: colors.text }]}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
accessibilityLabel="Search trails"
<View style={{ flex: 1 }}>
<SearchFilterControls
blurTarget={Platform.OS === "android" ? blurTargetRef : undefined}
query={query}
onQueryChange={setQuery}
sort={sort}
onSortChange={setSort}
sortOptions={SORTS}
searchPlaceholder="Search trails, regions, tags"
filterTitle="Filter trails"
onClearFilters={clearFilters}
applyFiltersLabel={`Show ${visible.length} ${visible.length === 1 ? "trail" : "trails"}`}
accessibilityLabels={{
searchInput: "Search trails",
openSearch: "Open trail search",
openFilters: "Open trail filters",
sortHint: "Shows trail sorting options",
}}
translateY={controlsTranslateY}
interactive={controlsInteractive}
>
<FilterSection title="Region">
<FilterChip
label="All regions"
selected={region === null}
onPress={() => setRegion(null)}
/>
{query.length > 0 ? (
<Pressable
onPress={() => setQuery('')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Clear search"
>
<Ionicons name="close-circle" size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>

<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chipRow}
>
<Chip label="All regions" selected={region === null} onPress={() => setRegion(null)} />
{regions.map((name) => (
<Chip
<FilterChip
key={name}
label={name}
selected={region === name}
onPress={() => setRegion(region === name ? null : name)}
/>
))}
</ScrollView>
</FilterSection>

<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chipRow}
>
<Chip label="Any level" selected={difficulty === null} onPress={() => setDifficulty(null)} />
<FilterSection title="Level">
<FilterChip
label="Any level"
selected={difficulty === null}
onPress={() => setDifficulty(null)}
/>
{DIFFICULTIES.map((level) => (
<Chip
<FilterChip
key={level}
label={difficultyLabel(level)}
selected={difficulty === level}
onPress={() => setDifficulty(difficulty === level ? null : level)}
onPress={() =>
setDifficulty(difficulty === level ? null : level)
}
/>
))}
</ScrollView>

<View style={styles.sortRow}>
<Text style={[styles.resultCount, { color: colors.textMuted }]}>
{visible.length} {visible.length === 1 ? 'trail' : 'trails'}
</Text>
<View style={[styles.segment, { backgroundColor: colors.surfaceAlt }]}>
{SORTS.map((option) => {
const active = sort === option.key;
return (
<Pressable
key={option.key}
onPress={() => setSort(option.key)}
accessibilityRole="button"
accessibilityState={{ selected: active }}
accessibilityLabel={`Sort by ${option.label.toLowerCase()}`}
style={[styles.segmentItem, active && { backgroundColor: colors.surface }]}
>
<Text
style={[styles.segmentLabel, { color: active ? colors.text : colors.textMuted }]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>

<FlashList
data={visible}
keyExtractor={(row) => row.trail.id}
renderItem={({ item }) => (
<TrailCard trail={item.trail} distanceMeters={item.distanceMeters} />
)}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={
<View style={styles.empty}>
<Ionicons name="trail-sign-outline" size={40} color={colors.textMuted} />
<Text style={[styles.emptyTitle, { color: colors.text }]}>No trails found</Text>
<Text style={[styles.emptyBody, { color: colors.textMuted }]}>
Nothing matches the current search and filters.
</Text>
{filtered ? (
<Pressable
onPress={clearFilters}
accessibilityRole="button"
style={[styles.emptyAction, { borderColor: colors.border }]}
>
<Text style={[styles.emptyActionLabel, { color: colors.primary }]}>
Clear filters
</Text>
</Pressable>
) : null}
</View>
}
/>
</FilterSection>
</SearchFilterControls>

{Platform.OS === "android" ? (
<BlurTargetView ref={blurTargetRef} style={{ flex: 1 }}>
{trailList}
</BlurTargetView>
) : trailList}
</View>
);
}

function Chip({
label,
selected,
onPress,
}: {
label: string;
selected: boolean;
onPress: () => void;
}) {
const { colors } = useTheme();
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityState={{ selected }}
style={[
styles.chip,
{
backgroundColor: selected ? colors.primary : colors.surface,
borderColor: selected ? colors.primary : colors.border,
},
]}
>
<Text style={[styles.chipLabel, { color: selected ? colors.textInverse : colors.textMuted }]}>
{label}
</Text>
</Pressable>
);
}

const styles = StyleSheet.create({
screen: { flex: 1 },
header: { paddingTop: spacing.md, gap: spacing.sm },
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
paddingHorizontal: spacing.md,
height: 40,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
},
searchInput: { flex: 1, fontSize: 15, padding: 0 },
chipRow: { gap: spacing.sm, paddingHorizontal: spacing.lg },
chip: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
},
chipLabel: { fontSize: 13, fontWeight: '500' },
sortRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: spacing.xs,
},
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: {
marginTop: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
},
emptyActionLabel: { fontSize: 14, fontWeight: '600' },
});
Loading