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:
45
app/(tabs)/_layout.tsx
Normal file
45
app/(tabs)/_layout.tsx
Normal 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
112
app/(tabs)/explore.tsx
Normal 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'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
241
app/(tabs)/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user