summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/Collapsible.tsx45
-rw-r--r--components/ExternalLink.tsx24
-rw-r--r--components/HapticTab.tsx18
-rw-r--r--components/HelloWave.tsx40
-rw-r--r--components/ParallaxScrollView.tsx82
-rw-r--r--components/ThemedText.tsx60
-rw-r--r--components/ThemedView.tsx14
-rw-r--r--components/ui/IconSymbol.ios.tsx32
-rw-r--r--components/ui/IconSymbol.tsx41
-rw-r--r--components/ui/TabBarBackground.ios.tsx19
-rw-r--r--components/ui/TabBarBackground.tsx6
11 files changed, 381 insertions, 0 deletions
diff --git a/components/Collapsible.tsx b/components/Collapsible.tsx
new file mode 100644
index 0000000..55bff2f
--- /dev/null
+++ b/components/Collapsible.tsx
@@ -0,0 +1,45 @@
+import { PropsWithChildren, useState } from 'react';
+import { StyleSheet, TouchableOpacity } from 'react-native';
+
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import { IconSymbol } from '@/components/ui/IconSymbol';
+import { Colors } from '@/constants/Colors';
+import { useColorScheme } from '@/hooks/useColorScheme';
+
+export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const theme = useColorScheme() ?? 'light';
+
+ return (
+ <ThemedView>
+ <TouchableOpacity
+ style={styles.heading}
+ onPress={() => setIsOpen((value) => !value)}
+ activeOpacity={0.8}>
+ <IconSymbol
+ name="chevron.right"
+ size={18}
+ weight="medium"
+ color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
+ style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
+ />
+
+ <ThemedText type="defaultSemiBold">{title}</ThemedText>
+ </TouchableOpacity>
+ {isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
+ </ThemedView>
+ );
+}
+
+const styles = StyleSheet.create({
+ heading: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ content: {
+ marginTop: 6,
+ marginLeft: 24,
+ },
+});
diff --git a/components/ExternalLink.tsx b/components/ExternalLink.tsx
new file mode 100644
index 0000000..dfbd23e
--- /dev/null
+++ b/components/ExternalLink.tsx
@@ -0,0 +1,24 @@
+import { Href, Link } from 'expo-router';
+import { openBrowserAsync } from 'expo-web-browser';
+import { type ComponentProps } from 'react';
+import { Platform } from 'react-native';
+
+type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
+
+export function ExternalLink({ href, ...rest }: Props) {
+ return (
+ <Link
+ target="_blank"
+ {...rest}
+ href={href}
+ onPress={async (event) => {
+ if (Platform.OS !== 'web') {
+ // Prevent the default behavior of linking to the default browser on native.
+ event.preventDefault();
+ // Open the link in an in-app browser.
+ await openBrowserAsync(href);
+ }
+ }}
+ />
+ );
+}
diff --git a/components/HapticTab.tsx b/components/HapticTab.tsx
new file mode 100644
index 0000000..7f3981c
--- /dev/null
+++ b/components/HapticTab.tsx
@@ -0,0 +1,18 @@
+import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
+import { PlatformPressable } from '@react-navigation/elements';
+import * as Haptics from 'expo-haptics';
+
+export function HapticTab(props: BottomTabBarButtonProps) {
+ return (
+ <PlatformPressable
+ {...props}
+ onPressIn={(ev) => {
+ if (process.env.EXPO_OS === 'ios') {
+ // Add a soft haptic feedback when pressing down on the tabs.
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ props.onPressIn?.(ev);
+ }}
+ />
+ );
+}
diff --git a/components/HelloWave.tsx b/components/HelloWave.tsx
new file mode 100644
index 0000000..eb6ea61
--- /dev/null
+++ b/components/HelloWave.tsx
@@ -0,0 +1,40 @@
+import { useEffect } from 'react';
+import { StyleSheet } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withSequence,
+ withTiming,
+} from 'react-native-reanimated';
+
+import { ThemedText } from '@/components/ThemedText';
+
+export function HelloWave() {
+ const rotationAnimation = useSharedValue(0);
+
+ useEffect(() => {
+ rotationAnimation.value = withRepeat(
+ withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
+ 4 // Run the animation 4 times
+ );
+ }, [rotationAnimation]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${rotationAnimation.value}deg` }],
+ }));
+
+ return (
+ <Animated.View style={animatedStyle}>
+ <ThemedText style={styles.text}>👋</ThemedText>
+ </Animated.View>
+ );
+}
+
+const styles = StyleSheet.create({
+ text: {
+ fontSize: 28,
+ lineHeight: 32,
+ marginTop: -6,
+ },
+});
diff --git a/components/ParallaxScrollView.tsx b/components/ParallaxScrollView.tsx
new file mode 100644
index 0000000..5df1d75
--- /dev/null
+++ b/components/ParallaxScrollView.tsx
@@ -0,0 +1,82 @@
+import type { PropsWithChildren, ReactElement } from 'react';
+import { StyleSheet } from 'react-native';
+import Animated, {
+ interpolate,
+ useAnimatedRef,
+ useAnimatedStyle,
+ useScrollViewOffset,
+} from 'react-native-reanimated';
+
+import { ThemedView } from '@/components/ThemedView';
+import { useBottomTabOverflow } from '@/components/ui/TabBarBackground';
+import { useColorScheme } from '@/hooks/useColorScheme';
+
+const HEADER_HEIGHT = 250;
+
+type Props = PropsWithChildren<{
+ headerImage: ReactElement;
+ headerBackgroundColor: { dark: string; light: string };
+}>;
+
+export default function ParallaxScrollView({
+ children,
+ headerImage,
+ headerBackgroundColor,
+}: Props) {
+ const colorScheme = useColorScheme() ?? 'light';
+ const scrollRef = useAnimatedRef<Animated.ScrollView>();
+ const scrollOffset = useScrollViewOffset(scrollRef);
+ const bottom = useBottomTabOverflow();
+ const headerAnimatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [
+ {
+ translateY: interpolate(
+ scrollOffset.value,
+ [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
+ [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
+ ),
+ },
+ {
+ scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
+ },
+ ],
+ };
+ });
+
+ return (
+ <ThemedView style={styles.container}>
+ <Animated.ScrollView
+ ref={scrollRef}
+ scrollEventThrottle={16}
+ scrollIndicatorInsets={{ bottom }}
+ contentContainerStyle={{ paddingBottom: bottom }}>
+ <Animated.View
+ style={[
+ styles.header,
+ { backgroundColor: headerBackgroundColor[colorScheme] },
+ headerAnimatedStyle,
+ ]}>
+ {headerImage}
+ </Animated.View>
+ <ThemedView style={styles.content}>{children}</ThemedView>
+ </Animated.ScrollView>
+ </ThemedView>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ height: HEADER_HEIGHT,
+ overflow: 'hidden',
+ },
+ content: {
+ flex: 1,
+ padding: 32,
+ gap: 16,
+ overflow: 'hidden',
+ },
+});
diff --git a/components/ThemedText.tsx b/components/ThemedText.tsx
new file mode 100644
index 0000000..9d214a2
--- /dev/null
+++ b/components/ThemedText.tsx
@@ -0,0 +1,60 @@
+import { StyleSheet, Text, type TextProps } from 'react-native';
+
+import { useThemeColor } from '@/hooks/useThemeColor';
+
+export type ThemedTextProps = TextProps & {
+ lightColor?: string;
+ darkColor?: string;
+ type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
+};
+
+export function ThemedText({
+ style,
+ lightColor,
+ darkColor,
+ type = 'default',
+ ...rest
+}: ThemedTextProps) {
+ const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
+
+ return (
+ <Text
+ style={[
+ { color },
+ type === 'default' ? styles.default : undefined,
+ type === 'title' ? styles.title : undefined,
+ type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
+ type === 'subtitle' ? styles.subtitle : undefined,
+ type === 'link' ? styles.link : undefined,
+ style,
+ ]}
+ {...rest}
+ />
+ );
+}
+
+const styles = StyleSheet.create({
+ default: {
+ fontSize: 16,
+ lineHeight: 24,
+ },
+ defaultSemiBold: {
+ fontSize: 16,
+ lineHeight: 24,
+ fontWeight: '600',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ lineHeight: 32,
+ },
+ subtitle: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ link: {
+ lineHeight: 30,
+ fontSize: 16,
+ color: '#0a7ea4',
+ },
+});
diff --git a/components/ThemedView.tsx b/components/ThemedView.tsx
new file mode 100644
index 0000000..4d2cb09
--- /dev/null
+++ b/components/ThemedView.tsx
@@ -0,0 +1,14 @@
+import { View, type ViewProps } from 'react-native';
+
+import { useThemeColor } from '@/hooks/useThemeColor';
+
+export type ThemedViewProps = ViewProps & {
+ lightColor?: string;
+ darkColor?: string;
+};
+
+export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
+ const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
+
+ return <View style={[{ backgroundColor }, style]} {...otherProps} />;
+}
diff --git a/components/ui/IconSymbol.ios.tsx b/components/ui/IconSymbol.ios.tsx
new file mode 100644
index 0000000..9177f4d
--- /dev/null
+++ b/components/ui/IconSymbol.ios.tsx
@@ -0,0 +1,32 @@
+import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
+import { StyleProp, ViewStyle } from 'react-native';
+
+export function IconSymbol({
+ name,
+ size = 24,
+ color,
+ style,
+ weight = 'regular',
+}: {
+ name: SymbolViewProps['name'];
+ size?: number;
+ color: string;
+ style?: StyleProp<ViewStyle>;
+ weight?: SymbolWeight;
+}) {
+ return (
+ <SymbolView
+ weight={weight}
+ tintColor={color}
+ resizeMode="scaleAspectFit"
+ name={name}
+ style={[
+ {
+ width: size,
+ height: size,
+ },
+ style,
+ ]}
+ />
+ );
+}
diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx
new file mode 100644
index 0000000..b7ece6b
--- /dev/null
+++ b/components/ui/IconSymbol.tsx
@@ -0,0 +1,41 @@
+// Fallback for using MaterialIcons on Android and web.
+
+import MaterialIcons from '@expo/vector-icons/MaterialIcons';
+import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
+import { ComponentProps } from 'react';
+import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
+
+type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
+type IconSymbolName = keyof typeof MAPPING;
+
+/**
+ * Add your SF Symbols to Material Icons mappings here.
+ * - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
+ * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
+ */
+const MAPPING = {
+ 'house.fill': 'home',
+ 'paperplane.fill': 'send',
+ 'chevron.left.forwardslash.chevron.right': 'code',
+ 'chevron.right': 'chevron-right',
+} as IconMapping;
+
+/**
+ * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
+ * This ensures a consistent look across platforms, and optimal resource usage.
+ * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
+ */
+export function IconSymbol({
+ name,
+ size = 24,
+ color,
+ style,
+}: {
+ name: IconSymbolName;
+ size?: number;
+ color: string | OpaqueColorValue;
+ style?: StyleProp<TextStyle>;
+ weight?: SymbolWeight;
+}) {
+ return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
+}
diff --git a/components/ui/TabBarBackground.ios.tsx b/components/ui/TabBarBackground.ios.tsx
new file mode 100644
index 0000000..495b2d4
--- /dev/null
+++ b/components/ui/TabBarBackground.ios.tsx
@@ -0,0 +1,19 @@
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
+import { BlurView } from 'expo-blur';
+import { StyleSheet } from 'react-native';
+
+export default function BlurTabBarBackground() {
+ return (
+ <BlurView
+ // System chrome material automatically adapts to the system's theme
+ // and matches the native tab bar appearance on iOS.
+ tint="systemChromeMaterial"
+ intensity={100}
+ style={StyleSheet.absoluteFill}
+ />
+ );
+}
+
+export function useBottomTabOverflow() {
+ return useBottomTabBarHeight();
+}
diff --git a/components/ui/TabBarBackground.tsx b/components/ui/TabBarBackground.tsx
new file mode 100644
index 0000000..70d1c3c
--- /dev/null
+++ b/components/ui/TabBarBackground.tsx
@@ -0,0 +1,6 @@
+// This is a shim for web and Android where the tab bar is generally opaque.
+export default undefined;
+
+export function useBottomTabOverflow() {
+ return 0;
+}