feat: new project
This commit is contained in:
66
app/(tabs)/_layout.tsx
Normal file
66
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { Link, Tabs } from 'expo-router';
|
||||
import { Pressable } from 'react-native';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
|
||||
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
function TabBarIcon(props: {
|
||||
name: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
color: string;
|
||||
}) {
|
||||
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
// Disable the static render of the header on web
|
||||
// to prevent a hydration error in React Navigation v6.
|
||||
headerShown: useClientOnlyValue(false, true),
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Tab One',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||
headerRight: () => (
|
||||
<Link href="/modal" asChild>
|
||||
<Pressable>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={25}
|
||||
color={Colors[colorScheme ?? 'light'].text}
|
||||
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Tab Two',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="demo"
|
||||
options={{
|
||||
title: '完整示例',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="rocket" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
686
app/(tabs)/demo.tsx
Normal file
686
app/(tabs)/demo.tsx
Normal file
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* 完整示例页面
|
||||
* 展示所有工具的使用方法
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Switch,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
// 导入所有工具
|
||||
import {
|
||||
// 工具函数
|
||||
Storage,
|
||||
STORAGE_KEYS,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
formatChatTime,
|
||||
|
||||
// 状态管理
|
||||
useUserStore,
|
||||
useUser,
|
||||
useIsLoggedIn,
|
||||
useSettingsStore,
|
||||
useTheme,
|
||||
useLanguage,
|
||||
useHapticsEnabled,
|
||||
|
||||
// 验证规则
|
||||
loginSchema,
|
||||
type LoginFormData,
|
||||
|
||||
// API 服务
|
||||
authService,
|
||||
|
||||
// 自定义 Hooks
|
||||
useDebounce,
|
||||
useThrottle,
|
||||
useHaptics,
|
||||
} from '@/src';
|
||||
|
||||
export default function DemoScreen() {
|
||||
const haptics = useHaptics();
|
||||
|
||||
// 状态管理示例
|
||||
const user = useUser();
|
||||
const isLoggedIn = useIsLoggedIn();
|
||||
const login = useUserStore((state) => state.login);
|
||||
const logout = useUserStore((state) => state.logout);
|
||||
|
||||
// 设置状态
|
||||
const theme = useTheme();
|
||||
const language = useLanguage();
|
||||
const hapticsEnabled = useHapticsEnabled();
|
||||
const setTheme = useSettingsStore((state) => state.setTheme);
|
||||
const setLanguage = useSettingsStore((state) => state.setLanguage);
|
||||
const setHapticsEnabled = useSettingsStore((state) => state.setHapticsEnabled);
|
||||
|
||||
// 本地状态
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [storageValue, setStorageValue] = useState('');
|
||||
|
||||
// 表单配置
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
// 防抖搜索示例
|
||||
const debouncedSearch = useDebounce(async (text: string) => {
|
||||
if (!text.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('执行搜索:', text);
|
||||
// 模拟 API 调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setSearchResults([
|
||||
`结果 1: ${text}`,
|
||||
`结果 2: ${text}`,
|
||||
`结果 3: ${text}`,
|
||||
]);
|
||||
}, 500);
|
||||
|
||||
// 监听搜索文本变化
|
||||
useEffect(() => {
|
||||
debouncedSearch(searchText);
|
||||
}, [searchText]);
|
||||
|
||||
// 节流点击示例
|
||||
const throttledClick = useThrottle(() => {
|
||||
haptics.light();
|
||||
setCounter((prev) => prev + 1);
|
||||
console.log('节流点击:', counter + 1);
|
||||
}, 1000);
|
||||
|
||||
// 登录处理
|
||||
const onLogin = async (data: LoginFormData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
haptics.light();
|
||||
|
||||
// 模拟登录 API 调用
|
||||
console.log('登录数据:', data);
|
||||
|
||||
// 实际项目中使用:
|
||||
// const { user, token } = await authService.login(data);
|
||||
// login(user, token);
|
||||
|
||||
// 模拟登录成功
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
username: data.email.split('@')[0],
|
||||
email: data.email,
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
nickname: '演示用户',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
login(mockUser, 'mock-token-123456');
|
||||
haptics.success();
|
||||
Alert.alert('成功', '登录成功!');
|
||||
} catch (error: any) {
|
||||
haptics.error();
|
||||
Alert.alert('失败', error.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = () => {
|
||||
haptics.warning();
|
||||
Alert.alert(
|
||||
'确认',
|
||||
'确定要退出登录吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => {
|
||||
logout();
|
||||
haptics.success();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 存储示例
|
||||
const handleSaveToStorage = async () => {
|
||||
try {
|
||||
haptics.light();
|
||||
const testData = {
|
||||
message: 'Hello from AsyncStorage!',
|
||||
timestamp: new Date().toISOString(),
|
||||
counter,
|
||||
};
|
||||
|
||||
await Storage.setObject(STORAGE_KEYS.USER_PREFERENCES, testData);
|
||||
haptics.success();
|
||||
Alert.alert('成功', '数据已保存到本地存储');
|
||||
} catch (error) {
|
||||
haptics.error();
|
||||
Alert.alert('失败', '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadFromStorage = async () => {
|
||||
try {
|
||||
haptics.light();
|
||||
const data = await Storage.getObject<any>(STORAGE_KEYS.USER_PREFERENCES);
|
||||
|
||||
if (data) {
|
||||
setStorageValue(JSON.stringify(data, null, 2));
|
||||
haptics.success();
|
||||
} else {
|
||||
setStorageValue('暂无数据');
|
||||
haptics.warning();
|
||||
}
|
||||
} catch (error) {
|
||||
haptics.error();
|
||||
Alert.alert('失败', '读取失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 主题切换
|
||||
const handleThemeChange = () => {
|
||||
haptics.selection();
|
||||
const themes: Array<'light' | 'dark' | 'auto'> = ['light', 'dark', 'auto'];
|
||||
const currentIndex = themes.indexOf(theme);
|
||||
const nextTheme = themes[(currentIndex + 1) % themes.length];
|
||||
setTheme(nextTheme);
|
||||
};
|
||||
|
||||
// 语言切换
|
||||
const handleLanguageChange = () => {
|
||||
haptics.selection();
|
||||
setLanguage(language === 'zh-CN' ? 'en-US' : 'zh-CN');
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{/* 标题 */}
|
||||
<Text style={styles.title}>🎯 完整功能演示</Text>
|
||||
<Text style={styles.subtitle}>展示所有工具的使用方法</Text>
|
||||
|
||||
{/* 用户状态显示 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>👤 用户状态 (Zustand)</Text>
|
||||
{isLoggedIn ? (
|
||||
<View style={styles.userInfo}>
|
||||
{user?.avatar && (
|
||||
<Image
|
||||
source={{ uri: user.avatar }}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<View style={styles.userDetails}>
|
||||
<Text style={styles.userName}>{user?.nickname}</Text>
|
||||
<Text style={styles.userEmail}>{user?.email}</Text>
|
||||
<Text style={styles.userDate}>
|
||||
注册时间: {formatRelativeTime(user?.createdAt || '')}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.logoutButton]}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<Text style={styles.buttonText}>退出</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.infoText}>未登录</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 登录表单 */}
|
||||
{!isLoggedIn && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>🔐 登录表单 (React Hook Form + Zod)</Text>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>邮箱</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="请输入邮箱"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
style={[
|
||||
styles.input,
|
||||
errors.email && styles.inputError,
|
||||
]}
|
||||
/>
|
||||
{errors.email && (
|
||||
<Text style={styles.errorText}>{errors.email.message}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>密码</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="请输入密码"
|
||||
secureTextEntry
|
||||
style={[
|
||||
styles.input,
|
||||
errors.password && styles.inputError,
|
||||
]}
|
||||
/>
|
||||
{errors.password && (
|
||||
<Text style={styles.errorText}>{errors.password.message}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.loginButton]}
|
||||
onPress={handleSubmit(onLogin)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>登录</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 搜索示例 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>🔍 防抖搜索 (useDebounce)</Text>
|
||||
<TextInput
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
placeholder="输入搜索内容..."
|
||||
style={styles.input}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<View style={styles.searchResults}>
|
||||
{searchResults.map((result, index) => (
|
||||
<Text key={index} style={styles.searchResult}>
|
||||
{result}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 节流点击示例 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>⏱️ 节流点击 (useThrottle)</Text>
|
||||
<Text style={styles.infoText}>点击次数: {counter}</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton]}
|
||||
onPress={throttledClick}
|
||||
>
|
||||
<Text style={styles.buttonText}>快速点击测试(1秒节流)</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 本地存储示例 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>💾 本地存储 (AsyncStorage)</Text>
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton, styles.halfButton]}
|
||||
onPress={handleSaveToStorage}
|
||||
>
|
||||
<Text style={styles.buttonText}>保存数据</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton, styles.halfButton]}
|
||||
onPress={handleLoadFromStorage}
|
||||
>
|
||||
<Text style={styles.buttonText}>读取数据</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{storageValue && (
|
||||
<View style={styles.codeBlock}>
|
||||
<Text style={styles.codeText}>{storageValue}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 日期格式化示例 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>📅 日期格式化 (Day.js)</Text>
|
||||
<Text style={styles.infoText}>
|
||||
当前时间: {formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
相对时间: {formatRelativeTime(new Date())}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
聊天时间: {formatChatTime(Date.now())}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 设置示例 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>⚙️ 应用设置</Text>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>主题: {theme}</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.smallButton]}
|
||||
onPress={handleThemeChange}
|
||||
>
|
||||
<Text style={styles.buttonText}>切换</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>语言: {language}</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.smallButton]}
|
||||
onPress={handleLanguageChange}
|
||||
>
|
||||
<Text style={styles.buttonText}>切换</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>触觉反馈</Text>
|
||||
<Switch
|
||||
value={hapticsEnabled}
|
||||
onValueChange={(value) => {
|
||||
haptics.selection();
|
||||
setHapticsEnabled(value);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 触觉反馈示例 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>📳 触觉反馈 (Expo Haptics)</Text>
|
||||
<View style={styles.buttonGrid}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.hapticsButton]}
|
||||
onPress={() => haptics.light()}
|
||||
>
|
||||
<Text style={styles.buttonText}>Light</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.hapticsButton]}
|
||||
onPress={() => haptics.medium()}
|
||||
>
|
||||
<Text style={styles.buttonText}>Medium</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.hapticsButton]}
|
||||
onPress={() => haptics.heavy()}
|
||||
>
|
||||
<Text style={styles.buttonText}>Heavy</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.successButton]}
|
||||
onPress={() => haptics.success()}
|
||||
>
|
||||
<Text style={styles.buttonText}>Success</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.warningButton]}
|
||||
onPress={() => haptics.warning()}
|
||||
>
|
||||
<Text style={styles.buttonText}>Warning</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.errorButton]}
|
||||
onPress={() => haptics.error()}
|
||||
>
|
||||
<Text style={styles.buttonText}>Error</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
查看代码了解更多使用方法 📖
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
userDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
userDate: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 4,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 6,
|
||||
color: '#333',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#ff3b30',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ff3b30',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
marginTop: 8,
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: '#ff3b30',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: '#5856D6',
|
||||
},
|
||||
smallButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
halfButton: {
|
||||
flex: 1,
|
||||
},
|
||||
searchResults: {
|
||||
marginTop: 12,
|
||||
},
|
||||
searchResult: {
|
||||
padding: 12,
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
codeBlock: {
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginTop: 12,
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: '#333',
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
settingLabel: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
buttonGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
hapticsButton: {
|
||||
backgroundColor: '#5856D6',
|
||||
flex: 1,
|
||||
minWidth: '30%',
|
||||
},
|
||||
successButton: {
|
||||
backgroundColor: '#34C759',
|
||||
flex: 1,
|
||||
minWidth: '30%',
|
||||
},
|
||||
warningButton: {
|
||||
backgroundColor: '#FF9500',
|
||||
flex: 1,
|
||||
minWidth: '30%',
|
||||
},
|
||||
errorButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
flex: 1,
|
||||
minWidth: '30%',
|
||||
},
|
||||
footer: {
|
||||
marginTop: 24,
|
||||
marginBottom: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
},
|
||||
});
|
||||
|
||||
192
app/(tabs)/index.tsx
Normal file
192
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
|
||||
import * as Updates from 'expo-updates';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function TabOneScreen() {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<string>('');
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (__DEV__) {
|
||||
Alert.alert('提示', '开发模式下无法检查更新,请使用生产构建测试热更新功能');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(true);
|
||||
setUpdateInfo('正在检查更新...');
|
||||
|
||||
try {
|
||||
const update = await Updates.checkForUpdateAsync();
|
||||
|
||||
if (update.isAvailable) {
|
||||
setUpdateInfo('发现新版本,正在下载...');
|
||||
await Updates.fetchUpdateAsync();
|
||||
|
||||
Alert.alert(
|
||||
'更新完成',
|
||||
'新版本已下载完成,是否立即重启应用?',
|
||||
[
|
||||
{
|
||||
text: '稍后',
|
||||
style: 'cancel',
|
||||
onPress: () => setUpdateInfo('更新已下载,稍后重启应用即可应用'),
|
||||
},
|
||||
{
|
||||
text: '立即重启',
|
||||
onPress: async () => {
|
||||
await Updates.reloadAsync();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
setUpdateInfo('当前已是最新版本');
|
||||
}
|
||||
} catch (error) {
|
||||
setUpdateInfo('检查更新失败: ' + (error as Error).message);
|
||||
Alert.alert('错误', '检查更新失败,请稍后重试');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUpdateInfo = () => {
|
||||
const {
|
||||
isEmbeddedLaunch,
|
||||
isEmergencyLaunch,
|
||||
updateId,
|
||||
channel,
|
||||
runtimeVersion,
|
||||
} = Updates.useUpdates();
|
||||
|
||||
return `
|
||||
运行模式: ${__DEV__ ? '开发模式' : '生产模式'}
|
||||
是否为内嵌启动: ${isEmbeddedLaunch ? '是' : '否'}
|
||||
是否为紧急启动: ${isEmergencyLaunch ? '是' : '否'}
|
||||
更新 ID: ${updateId || '无'}
|
||||
更新通道: ${channel || '无'}
|
||||
运行时版本: ${runtimeVersion || '无'}
|
||||
`.trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>🚀 热更新演示</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
|
||||
<View style={styles.infoContainer}>
|
||||
<Text style={styles.infoTitle}>当前版本信息:</Text>
|
||||
<Text style={styles.infoText}>{getUpdateInfo()}</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isChecking && styles.buttonDisabled]}
|
||||
onPress={checkForUpdates}
|
||||
disabled={isChecking}
|
||||
>
|
||||
{isChecking ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>检查更新</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{updateInfo ? (
|
||||
<View style={styles.updateInfoContainer}>
|
||||
<Text style={styles.updateInfoText}>{updateInfo}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.instructionsContainer}>
|
||||
<Text style={styles.instructionsTitle}>📝 使用说明:</Text>
|
||||
<Text style={styles.instructionsText}>
|
||||
1. 使用 EAS Build 构建生产版本{'\n'}
|
||||
2. 修改代码后运行 eas update 发布更新{'\n'}
|
||||
3. 打开应用点击"检查更新"按钮{'\n'}
|
||||
4. 应用会自动下载并提示重启
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 20,
|
||||
height: 1,
|
||||
width: '80%',
|
||||
},
|
||||
infoContainer: {
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||
padding: 15,
|
||||
borderRadius: 10,
|
||||
marginBottom: 20,
|
||||
width: '100%',
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: 18,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingHorizontal: 30,
|
||||
paddingVertical: 15,
|
||||
borderRadius: 10,
|
||||
marginBottom: 20,
|
||||
minWidth: 200,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#999',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
updateInfoContainer: {
|
||||
backgroundColor: 'rgba(52, 199, 89, 0.1)',
|
||||
padding: 15,
|
||||
borderRadius: 10,
|
||||
marginBottom: 20,
|
||||
width: '100%',
|
||||
},
|
||||
updateInfoText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
instructionsContainer: {
|
||||
backgroundColor: 'rgba(255, 149, 0, 0.1)',
|
||||
padding: 15,
|
||||
borderRadius: 10,
|
||||
width: '100%',
|
||||
},
|
||||
instructionsTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
instructionsText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
31
app/(tabs)/two.tsx
Normal file
31
app/(tabs)/two.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Tab Two</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/(tabs)/two.tsx" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 30,
|
||||
height: 1,
|
||||
width: '80%',
|
||||
},
|
||||
});
|
||||
38
app/+html.tsx
Normal file
38
app/+html.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
40
app/+not-found.tsx
Normal file
40
app/+not-found.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: '#2e78b7',
|
||||
},
|
||||
});
|
||||
102
app/_layout.tsx
Normal file
102
app/_layout.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import * as Updates from 'expo-updates';
|
||||
import { useEffect } from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
ErrorBoundary,
|
||||
} from 'expo-router';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
...FontAwesome.font,
|
||||
});
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
// 检查热更新
|
||||
useEffect(() => {
|
||||
async function checkForUpdates() {
|
||||
if (__DEV__) {
|
||||
// 开发模式下不检查更新
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const update = await Updates.checkForUpdateAsync();
|
||||
|
||||
if (update.isAvailable) {
|
||||
await Updates.fetchUpdateAsync();
|
||||
|
||||
// 提示用户重启应用以应用更新
|
||||
Alert.alert(
|
||||
'更新可用',
|
||||
'发现新版本,是否立即重启应用?',
|
||||
[
|
||||
{
|
||||
text: '稍后',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '立即重启',
|
||||
onPress: async () => {
|
||||
await Updates.reloadAsync();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理更新检查错误
|
||||
console.error('检查更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
checkForUpdates();
|
||||
}, []);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
35
app/modal.tsx
Normal file
35
app/modal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Modal</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/modal.tsx" />
|
||||
|
||||
{/* Use a light status bar on iOS to account for the black space above the modal */}
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
separator: {
|
||||
marginVertical: 30,
|
||||
height: 1,
|
||||
width: '80%',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user