feat: 集成 Redux 状态管理和用户目标管理功能
- 添加 Redux 状态管理,支持用户登录和个人信息的持久化 - 新增目标管理页面,允许用户设置每日卡路里和步数目标 - 更新首页,移除旧的活动展示,改为固定的热点功能卡片 - 修改布局以适应新功能的展示和交互 - 更新依赖,添加 @reduxjs/toolkit 和 react-redux 库以支持状态管理 - 新增 API 服务模块,处理与后端的交互
This commit is contained in:
@@ -2,25 +2,13 @@ import { PlanCard } from '@/components/PlanCard';
|
|||||||
import { SearchBox } from '@/components/SearchBox';
|
import { SearchBox } from '@/components/SearchBox';
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
import { WorkoutCard } from '@/components/WorkoutCard';
|
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||||
import { getChineseGreeting } from '@/utils/date';
|
import { getChineseGreeting } from '@/utils/date';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
const workoutData = [
|
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'AI体态评估',
|
|
||||||
duration: 5,
|
|
||||||
imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: '认证教练',
|
|
||||||
imageSource: require('@/assets/images/react-logo.png'),
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -46,34 +34,33 @@ export default function HomeScreen() {
|
|||||||
{/* Search Box */}
|
{/* Search Box */}
|
||||||
<SearchBox placeholder="搜索" />
|
<SearchBox placeholder="搜索" />
|
||||||
|
|
||||||
{/* Popular Workouts Section */}
|
{/* Hot Features Section */}
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
<ThemedText style={styles.sectionTitle}>热门活动</ThemedText>
|
<ThemedText style={styles.sectionTitle}>热点功能</ThemedText>
|
||||||
|
|
||||||
<ScrollView
|
<View style={styles.featureGrid}>
|
||||||
horizontal
|
<Pressable
|
||||||
showsHorizontalScrollIndicator={false}
|
style={[styles.featureCard, styles.featureCardPrimary]}
|
||||||
contentContainerStyle={styles.workoutScrollContainer}
|
onPress={() => router.push('/ai-posture-assessment')}
|
||||||
style={styles.workoutScroll}
|
>
|
||||||
>
|
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
|
||||||
{workoutData.map((workout) => (
|
<ThemedText style={styles.featureSubtitle}>3分钟获取体态报告</ThemedText>
|
||||||
<WorkoutCard
|
<View style={styles.featureCta}>
|
||||||
key={workout.id}
|
<ThemedText style={styles.featureCtaText}>开始评估</ThemedText>
|
||||||
title={workout.title}
|
</View>
|
||||||
duration={workout.duration}
|
</Pressable>
|
||||||
imageSource={workout.imageSource}
|
|
||||||
onPress={() => {
|
<Pressable
|
||||||
if (workout.title === 'AI体态评估') {
|
style={[styles.featureCard, styles.featureCardSecondary]}
|
||||||
router.push('/ai-posture-assessment');
|
onPress={() => router.push('/health-consultation' as any)}
|
||||||
} else if (workout.title === '认证教练') {
|
>
|
||||||
router.push('/health-consultation' as any);
|
<ThemedText style={styles.featureTitle}>在线教练</ThemedText>
|
||||||
} else {
|
<ThemedText style={styles.featureSubtitle}>认证教练 · 1对1即时解答</ThemedText>
|
||||||
console.log(`Pressed ${workout.title}`);
|
<View style={styles.featureCta}>
|
||||||
}
|
<ThemedText style={styles.featureCtaText}>立即咨询</ThemedText>
|
||||||
}}
|
</View>
|
||||||
/>
|
</Pressable>
|
||||||
))}
|
</View>
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Today Plan Section */}
|
{/* Today Plan Section */}
|
||||||
@@ -144,15 +131,62 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
},
|
},
|
||||||
|
featureGrid: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
featureCard: {
|
||||||
|
width: '48%',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
// iOS shadow
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
// Android shadow
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
featureCardPrimary: {
|
||||||
|
backgroundColor: '#EEF2FF', // 柔和的靛蓝背景
|
||||||
|
},
|
||||||
|
featureCardSecondary: {
|
||||||
|
backgroundColor: '#F0FDFA', // 柔和的青绿背景
|
||||||
|
},
|
||||||
|
featureIcon: {
|
||||||
|
fontSize: 28,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
featureTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
featureSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
lineHeight: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
featureCta: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
featureCtaText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
planList: {
|
planList: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
},
|
},
|
||||||
workoutScroll: {
|
// 移除旧的滑动样式
|
||||||
paddingLeft: 24,
|
|
||||||
},
|
|
||||||
workoutScrollContainer: {
|
|
||||||
paddingRight: 24,
|
|
||||||
},
|
|
||||||
bottomSpacing: {
|
bottomSpacing: {
|
||||||
height: 120,
|
height: 120,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import type { Href } from 'expo-router';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||||
Alert,
|
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
|
||||||
StatusBar,
|
|
||||||
StyleSheet,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View
|
|
||||||
} from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function PersonalScreen() {
|
export default function PersonalScreen() {
|
||||||
@@ -41,6 +33,7 @@ export default function PersonalScreen() {
|
|||||||
avatarUri?: string | null;
|
avatarUri?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userProfileFromRedux = useAppSelector((s) => s.user.profile);
|
||||||
const [profile, setProfile] = useState<UserProfile>({});
|
const [profile, setProfile] = useState<UserProfile>({});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -73,6 +66,11 @@ export default function PersonalScreen() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
useFocusEffect(React.useCallback(() => { load(); return () => { }; }, []));
|
useFocusEffect(React.useCallback(() => { load(); return () => { }; }, []));
|
||||||
|
useEffect(() => {
|
||||||
|
if (userProfileFromRedux) {
|
||||||
|
setProfile(userProfileFromRedux);
|
||||||
|
}
|
||||||
|
}, [userProfileFromRedux]);
|
||||||
|
|
||||||
const formatHeight = () => {
|
const formatHeight = () => {
|
||||||
if (profile.heightCm == null) return '--';
|
if (profile.heightCm == null) return '--';
|
||||||
@@ -234,23 +232,11 @@ export default function PersonalScreen() {
|
|||||||
|
|
||||||
const accountItems = [
|
const accountItems = [
|
||||||
{
|
{
|
||||||
icon: 'person-outline',
|
icon: 'flag-outline',
|
||||||
iconBg: '#E8F5E8',
|
iconBg: '#E8F5E8',
|
||||||
iconColor: '#4ADE80',
|
iconColor: '#4ADE80',
|
||||||
title: '个人资料',
|
title: '目标管理',
|
||||||
onPress: () => router.push('/profile/edit'),
|
onPress: () => router.push('/profile/goals' as Href),
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'trophy-outline',
|
|
||||||
iconBg: '#E8F5E8',
|
|
||||||
iconColor: '#4ADE80',
|
|
||||||
title: '成就',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'time-outline',
|
|
||||||
iconBg: '#E8F5E8',
|
|
||||||
iconColor: '#4ADE80',
|
|
||||||
title: '活动历史',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'stats-chart-outline',
|
icon: 'stats-chart-outline',
|
||||||
@@ -265,7 +251,7 @@ export default function PersonalScreen() {
|
|||||||
icon: 'notifications-outline',
|
icon: 'notifications-outline',
|
||||||
iconBg: '#E8F5E8',
|
iconBg: '#E8F5E8',
|
||||||
iconColor: '#4ADE80',
|
iconColor: '#4ADE80',
|
||||||
title: '弹窗通知',
|
title: '消息推送',
|
||||||
type: 'switch',
|
type: 'switch',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,7 +4,20 @@ import { Stack } from 'expo-router';
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { store } from '@/store';
|
||||||
|
import { rehydrateUser } from '@/store/userSlice';
|
||||||
|
import React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch(rehydrateUser());
|
||||||
|
}, [dispatch]);
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
@@ -18,18 +31,23 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<Provider store={store}>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Bootstrapper>
|
||||||
<Stack.Screen name="onboarding" />
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="profile/edit" />
|
<Stack.Screen name="onboarding" />
|
||||||
<Stack.Screen name="ai-posture-assessment" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
<Stack.Screen name="profile/edit" />
|
||||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
<Stack.Screen name="ai-posture-assessment" />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||||
</Stack>
|
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||||
<StatusBar style="auto" />
|
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||||
</ThemeProvider>
|
<Stack.Screen name="+not-found" />
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</ThemeProvider>
|
||||||
|
</Bootstrapper>
|
||||||
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Alert, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { login } from '@/store/userSlice';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const color = Colors[scheme];
|
const color = Colors[scheme];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
|
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
|
||||||
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
|
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
|
||||||
@@ -40,11 +44,13 @@ export default function LoginScreen() {
|
|||||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
// TODO: 将 credential 发送到后端换取应用会话。这里先直接返回上一页。
|
const identityToken = (credential as any)?.identityToken;
|
||||||
|
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||||
router.back();
|
router.back();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 'ERR_CANCELED') return;
|
if (err?.code === 'ERR_CANCELED') return;
|
||||||
Alert.alert('登录失败', '请稍后再试');
|
const message = err?.message || '登录失败,请稍后再试';
|
||||||
|
Alert.alert('登录失败', message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +64,7 @@ export default function LoginScreen() {
|
|||||||
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
|
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: color.background }]}>
|
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: color.background }]}>
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
{/* 自定义头部,与其它页面风格一致 */}
|
{/* 自定义头部,与其它页面风格一致 */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -71,7 +77,7 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
<View style={styles.headerWrap}>
|
<View style={styles.headerWrap}>
|
||||||
<ThemedText style={[styles.title, { color: color.text }]}>Digital Pilates</ThemedText>
|
<ThemedText style={[styles.title, { color: color.text }]}>数字普拉提</ThemedText>
|
||||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录</ThemedText>
|
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
408
app/profile/goals.tsx
Normal file
408
app/profile/goals.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
calories: '@goal_calories_burn',
|
||||||
|
steps: '@goal_daily_steps',
|
||||||
|
purposes: '@goal_pilates_purposes',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CALORIES_RANGE = { min: 100, max: 1500, step: 50 };
|
||||||
|
const STEPS_RANGE = { min: 2000, max: 20000, step: 500 };
|
||||||
|
|
||||||
|
export default function GoalsScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colors = Colors[theme];
|
||||||
|
|
||||||
|
const [calories, setCalories] = useState<number>(400);
|
||||||
|
const [steps, setSteps] = useState<number>(8000);
|
||||||
|
const [purposes, setPurposes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [c, s, p] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.calories),
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.steps),
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.purposes),
|
||||||
|
]);
|
||||||
|
if (c) {
|
||||||
|
const v = parseInt(c, 10);
|
||||||
|
if (!Number.isNaN(v)) setCalories(v);
|
||||||
|
}
|
||||||
|
if (s) {
|
||||||
|
const v = parseInt(s, 10);
|
||||||
|
if (!Number.isNaN(v)) setSteps(v);
|
||||||
|
}
|
||||||
|
if (p) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(p);
|
||||||
|
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => {});
|
||||||
|
}, [calories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => {});
|
||||||
|
}, [steps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => {});
|
||||||
|
}, [purposes]);
|
||||||
|
|
||||||
|
const caloriesPercent = useMemo(() =>
|
||||||
|
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
|
||||||
|
(CALORIES_RANGE.max - CALORIES_RANGE.min),
|
||||||
|
[calories]);
|
||||||
|
|
||||||
|
const stepsPercent = useMemo(() =>
|
||||||
|
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
|
||||||
|
(STEPS_RANGE.max - STEPS_RANGE.min),
|
||||||
|
[steps]);
|
||||||
|
|
||||||
|
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
setter(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inc = (value: number, range: { step: number; max: number }) => {
|
||||||
|
return Math.min(range.max, value + range.step);
|
||||||
|
};
|
||||||
|
const dec = (value: number, range: { step: number; min: number }) => {
|
||||||
|
return Math.max(range.min, value - range.step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }>
|
||||||
|
= ({ title, subtitle, children }) => (
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
|
||||||
|
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
|
||||||
|
= ({ label, active, onPress }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
|
||||||
|
= ({ onDec, onInc }) => (
|
||||||
|
<View style={styles.stepperRow}>
|
||||||
|
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
|
||||||
|
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
|
||||||
|
{ id: 'posture', label: '改善姿势体态', icon: 'body-outline' },
|
||||||
|
{ id: 'flexibility', label: '提高柔韧灵活', icon: 'walk-outline' },
|
||||||
|
{ id: 'balance', label: '强化平衡稳定', icon: 'accessibility-outline' },
|
||||||
|
{ id: 'shape', label: '塑形与线条', icon: 'heart-outline' },
|
||||||
|
{ id: 'stress', label: '减压与身心放松', icon: 'leaf-outline' },
|
||||||
|
{ id: 'backpain', label: '预防/改善腰背痛', icon: 'shield-checkmark-outline' },
|
||||||
|
{ id: 'rehab', label: '术后/伤后康复', icon: 'medkit-outline' },
|
||||||
|
{ id: 'performance', label: '提升运动表现', icon: 'fitness-outline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const togglePurpose = (id: string) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.selectionAsync();
|
||||||
|
}
|
||||||
|
setPurposes((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
||||||
|
{/* Header(参照 AI 体态测评页面的实现) */}
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={[styles.backButton, { backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }]}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={theme === 'dark' ? '#ECEDEE' : colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.headerTitle, { color: theme === 'dark' ? '#ECEDEE' : colors.text }]}>目标管理</Text>
|
||||||
|
<View style={{ width: 32 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={[styles.valueText, { color: colors.text }]}>{calories} kcal</Text>
|
||||||
|
<Stepper
|
||||||
|
onDec={() => changeWithHaptics(dec(calories, CALORIES_RANGE), setCalories)}
|
||||||
|
onInc={() => changeWithHaptics(inc(calories, CALORIES_RANGE), setCalories)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={caloriesPercent}
|
||||||
|
height={18}
|
||||||
|
style={styles.progressMargin}
|
||||||
|
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
|
||||||
|
fillColor={colors.primary}
|
||||||
|
/>
|
||||||
|
<View style={styles.chipsRow}>
|
||||||
|
{[200, 300, 400, 500, 600].map((v) => (
|
||||||
|
<PresetChip key={v}
|
||||||
|
label={`${v}`}
|
||||||
|
active={v === calories}
|
||||||
|
onPress={() => changeWithHaptics(v, setCalories)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.rangeHint, { color: colors.textMuted }]}>建议范围 {CALORIES_RANGE.min}-{CALORIES_RANGE.max} kcal,步进 {CALORIES_RANGE.step}</Text>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="每日步数目标" subtitle="快速设置你的目标步数">
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={[styles.valueText, { color: colors.text }]}>{steps.toLocaleString()} 步</Text>
|
||||||
|
<Stepper
|
||||||
|
onDec={() => changeWithHaptics(dec(steps, STEPS_RANGE), setSteps)}
|
||||||
|
onInc={() => changeWithHaptics(inc(steps, STEPS_RANGE), setSteps)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={stepsPercent}
|
||||||
|
height={18}
|
||||||
|
style={styles.progressMargin}
|
||||||
|
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
|
||||||
|
fillColor={colors.primary}
|
||||||
|
/>
|
||||||
|
<View style={styles.chipsRow}>
|
||||||
|
{[6000, 8000, 10000, 12000, 15000].map((v) => (
|
||||||
|
<PresetChip key={v}
|
||||||
|
label={`${v / 1000}k`}
|
||||||
|
active={v === steps}
|
||||||
|
onPress={() => changeWithHaptics(v, setSteps)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.rangeHint, { color: colors.textMuted }]}>建议范围 {STEPS_RANGE.min.toLocaleString()}-{STEPS_RANGE.max.toLocaleString()},步进 {STEPS_RANGE.step}</Text>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
title="练习普拉提是为了什么"
|
||||||
|
subtitle="可多选"
|
||||||
|
>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{PURPOSE_OPTIONS.map((opt) => {
|
||||||
|
const active = purposes.includes(opt.id);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={opt.id}
|
||||||
|
style={[
|
||||||
|
styles.optionCard,
|
||||||
|
{
|
||||||
|
backgroundColor: active ? colors.primary : colors.card,
|
||||||
|
borderColor: active ? colors.primary : colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => togglePurpose(opt.id)}
|
||||||
|
>
|
||||||
|
<View style={styles.optionIconWrap}>
|
||||||
|
<Ionicons
|
||||||
|
name={opt.icon}
|
||||||
|
size={20}
|
||||||
|
color={active ? colors.onPrimary : colors.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[
|
||||||
|
styles.optionLabel,
|
||||||
|
{ color: active ? colors.onPrimary : colors.text },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
{purposes.length > 0 && (
|
||||||
|
<Text style={[styles.selectedHint, { color: colors.textSecondary }]}>已选择 {purposes.length} 项</Text>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
rowBetween: {
|
||||||
|
marginTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
chipsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
stepperRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
stepperBtn: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
stepperText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
rangeHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
progressMargin: {
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
marginTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
rowGap: 12,
|
||||||
|
},
|
||||||
|
optionCard: {
|
||||||
|
width: '48%',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
optionIconWrap: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
selectedHint: {
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Animated, Easing, LayoutChangeEvent, StyleSheet, Text, View, ViewStyle } from 'react-native';
|
import { Animated, Easing, LayoutChangeEvent, StyleSheet, Text, View, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
type ProgressBarProps = {
|
type ProgressBarProps = {
|
||||||
@@ -11,7 +11,7 @@ type ProgressBarProps = {
|
|||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProgressBar({
|
function ProgressBarImpl({
|
||||||
progress,
|
progress,
|
||||||
height = 18,
|
height = 18,
|
||||||
style,
|
style,
|
||||||
@@ -60,6 +60,18 @@ export function ProgressBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ProgressBar = memo(ProgressBarImpl, (prev, next) => {
|
||||||
|
// 仅在这些关键属性变化时重新渲染,忽略 style 的引用变化
|
||||||
|
return (
|
||||||
|
prev.progress === next.progress &&
|
||||||
|
prev.height === next.height &&
|
||||||
|
prev.trackColor === next.trackColor &&
|
||||||
|
prev.fillColor === next.fillColor &&
|
||||||
|
prev.animated === next.animated &&
|
||||||
|
prev.showLabel === next.showLabel
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
13
constants/Api.ts
Normal file
13
constants/Api.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const API_ORIGIN = 'https://plate.richarjiang.com';
|
||||||
|
export const API_BASE_PATH = '/api';
|
||||||
|
|
||||||
|
export function buildApiUrl(path: string): string {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
// 如果传入的路径已包含 /api 前缀,则直接拼接域名;否则自动补上 /api
|
||||||
|
const finalPath = normalizedPath.startsWith(`${API_BASE_PATH}/`)
|
||||||
|
? normalizedPath
|
||||||
|
: `${API_BASE_PATH}${normalizedPath}`;
|
||||||
|
return `${API_ORIGIN}${finalPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
7
hooks/redux.ts
Normal file
7
hooks/redux.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { AppDispatch, RootState } from '@/store';
|
||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
<key>com.apple.developer.healthkit</key>
|
<key>com.apple.developer.healthkit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.healthkit.access</key>
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
<array/>
|
<array>
|
||||||
|
<string>health-records</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
102
package-lock.json
generated
102
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-apple-authentication": "6.4.2",
|
"expo-apple-authentication": "6.4.2",
|
||||||
@@ -40,7 +41,8 @@
|
|||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "^15.12.1",
|
"react-native-svg": "^15.12.1",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5"
|
"react-native-webview": "13.13.5",
|
||||||
|
"react-redux": "^9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
@@ -3123,6 +3125,32 @@
|
|||||||
"nanoid": "^3.3.11"
|
"nanoid": "^3.3.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^10.0.3",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3154,6 +3182,18 @@
|
|||||||
"@sinonjs/commons": "^3.0.0"
|
"@sinonjs/commons": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.0",
|
"version": "0.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
|
||||||
@@ -3290,6 +3330,12 @@
|
|||||||
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
|
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.33",
|
"version": "17.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||||
@@ -7442,6 +7488,16 @@
|
|||||||
"node": ">=16.x"
|
"node": ">=16.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -10817,6 +10873,29 @@
|
|||||||
"async-limiter": "~1.0.0"
|
"async-limiter": "~1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.14.2",
|
"version": "0.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||||
@@ -10826,6 +10905,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -10981,6 +11075,12 @@
|
|||||||
"path-parse": "^1.0.5"
|
"path-parse": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-blur": "~14.1.5",
|
|
||||||
"expo-apple-authentication": "6.4.2",
|
"expo-apple-authentication": "6.4.2",
|
||||||
|
"expo-blur": "~14.1.5",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
"expo-font": "~13.3.2",
|
"expo-font": "~13.3.2",
|
||||||
"expo-haptics": "~14.1.4",
|
"expo-haptics": "~14.1.4",
|
||||||
@@ -43,7 +44,8 @@
|
|||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "^15.12.1",
|
"react-native-svg": "^15.12.1",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5"
|
"react-native-webview": "13.13.5",
|
||||||
|
"react-redux": "^9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|||||||
88
services/api.ts
Normal file
88
services/api.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { buildApiUrl } from '@/constants/Api';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
|
||||||
|
let inMemoryToken: string | null = null;
|
||||||
|
|
||||||
|
export async function setAuthToken(token: string | null): Promise<void> {
|
||||||
|
inMemoryToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthToken(): string | null {
|
||||||
|
return inMemoryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiRequestOptions = {
|
||||||
|
method?: HttpMethod;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiResponse<T> = {
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||||
|
const url = buildApiUrl(path);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body != null ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let json: any = null;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 响应
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
|
||||||
|
const error = new Error(errorMessage);
|
||||||
|
// @ts-expect-error augment
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持后端返回 { data: ... } 或直接返回对象
|
||||||
|
return (json && (json.data ?? json)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'GET' }),
|
||||||
|
post: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'POST', body }),
|
||||||
|
put: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'PUT', body }),
|
||||||
|
patch: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'PATCH', body }),
|
||||||
|
delete: <T>(path: string, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
authToken: '@auth_token',
|
||||||
|
userProfile: '@user_profile',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function loadPersistedToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const t = await AsyncStorage.getItem(STORAGE_KEYS.authToken);
|
||||||
|
return t || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
14
store/index.ts
Normal file
14
store/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import userReducer from './userSlice';
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
user: userReducer,
|
||||||
|
},
|
||||||
|
// React Native 环境默认即可
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|
||||||
|
|
||||||
124
store/userSlice.ts
Normal file
124
store/userSlice.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export type Gender = 'male' | 'female' | '';
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
gender?: Gender;
|
||||||
|
age?: string; // 个人中心是字符串展示
|
||||||
|
weightKg?: number;
|
||||||
|
heightCm?: number;
|
||||||
|
avatarUri?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserState = {
|
||||||
|
token: string | null;
|
||||||
|
profile: UserProfile;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: UserState = {
|
||||||
|
token: null,
|
||||||
|
profile: {
|
||||||
|
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginPayload = Record<string, any> & {
|
||||||
|
// 可扩展:用户名密码、Apple 身份、短信验证码等
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
appleIdentityToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = createAsyncThunk(
|
||||||
|
'user/login',
|
||||||
|
async (payload: LoginPayload, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
// 后端路径允许传入 '/api/login' 或 'login'
|
||||||
|
const data = await api.post<{ token?: string; profile?: UserProfile } | (UserProfile & { token?: string })>(
|
||||||
|
'/api/login',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 兼容两种返回结构
|
||||||
|
const token = (data as any).token ?? (data as any)?.profile?.token ?? null;
|
||||||
|
const profile: UserProfile | null = (data as any).profile ?? (data as any);
|
||||||
|
|
||||||
|
if (!token) throw new Error('登录响应缺少 token');
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.authToken, token);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.userProfile, JSON.stringify(profile ?? {}));
|
||||||
|
await setAuthToken(token);
|
||||||
|
|
||||||
|
return { token, profile } as { token: string; profile: UserProfile };
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message ?? '登录失败';
|
||||||
|
return rejectWithValue(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
|
||||||
|
const [token, profileStr] = await Promise.all([
|
||||||
|
loadPersistedToken(),
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
|
||||||
|
]);
|
||||||
|
await setAuthToken(token);
|
||||||
|
let profile: UserProfile = {};
|
||||||
|
if (profileStr) {
|
||||||
|
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
|
||||||
|
}
|
||||||
|
return { token, profile } as { token: string | null; profile: UserProfile };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logout = createAsyncThunk('user/logout', async () => {
|
||||||
|
await Promise.all([
|
||||||
|
AsyncStorage.removeItem(STORAGE_KEYS.authToken),
|
||||||
|
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
|
||||||
|
]);
|
||||||
|
await setAuthToken(null);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSlice = createSlice({
|
||||||
|
name: 'user',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
updateProfile(state, action: PayloadAction<Partial<UserProfile>>) {
|
||||||
|
state.profile = { ...(state.profile ?? {}), ...action.payload };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(login.pending, (state) => { state.loading = true; state.error = null; })
|
||||||
|
.addCase(login.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.token = action.payload.token;
|
||||||
|
state.profile = action.payload.profile;
|
||||||
|
})
|
||||||
|
.addCase(login.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = (action.payload as string) ?? '登录失败';
|
||||||
|
})
|
||||||
|
.addCase(rehydrateUser.fulfilled, (state, action) => {
|
||||||
|
state.token = action.payload.token;
|
||||||
|
state.profile = action.payload.profile;
|
||||||
|
})
|
||||||
|
.addCase(logout.fulfilled, (state) => {
|
||||||
|
state.token = null;
|
||||||
|
state.profile = {};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { updateProfile } = userSlice.actions;
|
||||||
|
export default userSlice.reducer;
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user