Initial commit: Expo app with Better Auth integration

- Complete Expo React Native app setup with TypeScript
- Better Auth authentication system integration
- Secure storage implementation for session tokens
- Authentication flow with login/logout functionality
- API client configuration for backend communication
- Responsive UI components with themed styling
- Expo Router navigation setup
- Development configuration and scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-10-14 10:32:52 +08:00
commit 7e3f94bae3
71 changed files with 20800 additions and 0 deletions

45
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { Tabs, Redirect } from 'expo-router';
import React from 'react';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useAuth } from '@/hooks/use-auth';
export default function TabLayout() {
const colorScheme = useColorScheme();
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return null;
}
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>
);
}

112
app/(tabs)/explore.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native';
import { Collapsible } from '@/components/ui/collapsible';
import { ExternalLink } from '@/components/external-link';
import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Fonts } from '@/constants/theme';
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText
type="title"
style={{
fontFamily: Fonts.rounded,
}}>
Explore 222
</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image
source={require('@/assets/images/react-logo.png')}
style={{ width: 100, height: 100, alignSelf: 'center' }}
/>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{' '}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{' '}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful{' '}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{' '}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
});

241
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,241 @@
import { TemplateList } from '@/components/templates/template-list';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { useAuth } from '@/hooks/use-auth';
import { getTemplates } from '@/lib/api/templates';
import { Template } from '@/lib/types/template';
import { LinearGradient } from 'expo-linear-gradient';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, Animated, StyleSheet, Text } from 'react-native';
export default function HomeScreen() {
const { isAuthenticated } = useAuth();
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const pulseAnim = useRef(new Animated.Value(0)).current;
const bounceAnim = useRef(new Animated.Value(0)).current;
const fetchTemplates = useCallback(async (page: number = 1, isRefreshing = false, isLoadMore = false) => {
if (!isAuthenticated) return;
try {
if (isRefreshing) {
setRefreshing(true);
} else if (isLoadMore) {
setIsLoadingMore(true);
} else {
setLoading(true);
}
setError(null);
const response = await getTemplates({
page,
size: 10,
status: 'RELEASE'
});
if (isRefreshing || page === 1) {
// 刷新或首次加载,替换数据
setTemplates(response.data);
setCurrentPage(1);
} else {
// 加载更多,追加数据
setTemplates(prev => [...prev, ...response.data]);
setCurrentPage(page);
}
// 检查是否还有更多数据
setHasMore(response.pagination.page < response.pagination.totalPages);
} catch (err: any) {
console.error('获取模板列表失败:', err);
setError(err.message || '获取模板列表失败');
} finally {
setLoading(false);
setRefreshing(false);
setIsLoadingMore(false);
}
}, [isAuthenticated]);
const loadMore = useCallback(() => {
if (!isLoadingMore && hasMore && !refreshing) {
fetchTemplates(currentPage + 1, false, true);
}
}, [currentPage, hasMore, isLoadingMore, refreshing, fetchTemplates]);
useEffect(() => {
if (isAuthenticated) {
fetchTemplates();
}
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: false,
}),
Animated.timing(pulseAnim, {
toValue: 0,
duration: 2000,
useNativeDriver: false,
}),
])
).start();
Animated.loop(
Animated.sequence([
Animated.timing(bounceAnim, {
toValue: -10,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(bounceAnim, {
toValue: -5,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(bounceAnim, {
toValue: 0,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(bounceAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
])
).start();
}, [isAuthenticated, fetchTemplates, pulseAnim, bounceAnim]);
const handleRefresh = useCallback(() => {
fetchTemplates(1, true, false);
}, [fetchTemplates]);
const handleTemplatePress = (template: Template) => {
Alert.alert('模板详情', `您选择了: ${template.title}`);
};
if (!isAuthenticated) {
return (
<ThemedView style={styles.container}>
<ThemedView style={styles.centerContent}>
<ThemedText type="title" style={styles.title}>使</ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
</ThemedView>
</ThemedView>
);
}
const shadowOpacity = pulseAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 0.4],
});
return (
<ThemedView style={styles.container}>
<Animated.View style={[styles.headerBanner, { shadowOpacity }]}>
<LinearGradient
colors={['#ff6b6b', '#ff8e53', '#ff6b35']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradientBanner}
>
<Text style={styles.bannerText}>
New User 50% Off Coupon : <Text style={styles.couponText}>NEWUSER</Text>
</Text>
<Animated.Text
style={[
styles.giftIcon,
{ transform: [{ translateY: bounceAnim }] },
]}
>
🎁
</Animated.Text>
</LinearGradient>
</Animated.View>
<TemplateList
templates={templates}
loading={loading}
refreshing={refreshing}
isLoadingMore={isLoadingMore}
hasMore={hasMore}
onRefresh={handleRefresh}
onLoadMore={loadMore}
onTemplatePress={handleTemplatePress}
error={error}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerBanner: {
position: 'absolute',
top: 16,
left: '10%',
right: '10%',
zIndex: 1000,
borderRadius: 20,
shadowColor: '#ff6b6b',
shadowOffset: { width: 0, height: 4 },
shadowRadius: 12,
elevation: 8,
overflow: 'hidden',
},
gradientBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 4,
paddingHorizontal: 16,
position: 'relative',
},
bannerText: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
textAlign: 'center',
},
couponText: {
fontWeight: '800',
fontSize: 14,
color: '#fff',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 6,
marginLeft: 6,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.6)',
borderStyle: 'dashed',
},
giftIcon: {
position: 'absolute',
right: 8,
fontSize: 16,
},
centerContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
title: {
marginBottom: 8,
textAlign: 'center',
},
subtitle: {
textAlign: 'center',
opacity: 0.7,
},
});