diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4a2c2e6..2e541bd 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -2,25 +2,13 @@ import { PlanCard } from '@/components/PlanCard'; import { SearchBox } from '@/components/SearchBox'; import { ThemedText } from '@/components/ThemedText'; 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 { useRouter } from 'expo-router'; 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() { const router = useRouter(); @@ -46,34 +34,33 @@ export default function HomeScreen() { {/* Search Box */} - {/* Popular Workouts Section */} + {/* Hot Features Section */} - 热门活动 + 热点功能 - - {workoutData.map((workout) => ( - { - if (workout.title === 'AI体态评估') { - router.push('/ai-posture-assessment'); - } else if (workout.title === '认证教练') { - router.push('/health-consultation' as any); - } else { - console.log(`Pressed ${workout.title}`); - } - }} - /> - ))} - + + router.push('/ai-posture-assessment')} + > + AI体态评估 + 3分钟获取体态报告 + + 开始评估 + + + + router.push('/health-consultation' as any)} + > + 在线教练 + 认证教练 · 1对1即时解答 + + 立即咨询 + + + {/* Today Plan Section */} @@ -144,15 +131,62 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, 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: { paddingHorizontal: 24, }, - workoutScroll: { - paddingLeft: 24, - }, - workoutScrollContainer: { - paddingRight: 24, - }, + // 移除旧的滑动样式 bottomSpacing: { height: 120, }, diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 6dc6d04..e3d5808 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -1,23 +1,15 @@ import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; +import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; +import type { Href } from 'expo-router'; import { router } from 'expo-router'; import React, { useEffect, useMemo, useState } from 'react'; -import { - Alert, - SafeAreaView, - ScrollView, - StatusBar, - StyleSheet, - Switch, - Text, - TouchableOpacity, - View -} from 'react-native'; +import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function PersonalScreen() { @@ -41,6 +33,7 @@ export default function PersonalScreen() { avatarUri?: string | null; }; + const userProfileFromRedux = useAppSelector((s) => s.user.profile); const [profile, setProfile] = useState({}); const load = async () => { @@ -73,6 +66,11 @@ export default function PersonalScreen() { useEffect(() => { load(); }, []); useFocusEffect(React.useCallback(() => { load(); return () => { }; }, [])); + useEffect(() => { + if (userProfileFromRedux) { + setProfile(userProfileFromRedux); + } + }, [userProfileFromRedux]); const formatHeight = () => { if (profile.heightCm == null) return '--'; @@ -234,23 +232,11 @@ export default function PersonalScreen() { const accountItems = [ { - icon: 'person-outline', + icon: 'flag-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: '个人资料', - onPress: () => router.push('/profile/edit'), - }, - { - icon: 'trophy-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '成就', - }, - { - icon: 'time-outline', - iconBg: '#E8F5E8', - iconColor: '#4ADE80', - title: '活动历史', + title: '目标管理', + onPress: () => router.push('/profile/goals' as Href), }, { icon: 'stats-chart-outline', @@ -265,7 +251,7 @@ export default function PersonalScreen() { icon: 'notifications-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: '弹窗通知', + title: '消息推送', type: 'switch', }, ]; diff --git a/app/_layout.tsx b/app/_layout.tsx index 3c4c7de..bd921ee 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,7 +4,20 @@ import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; +import { useAppDispatch } from '@/hooks/redux'; 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() { const colorScheme = useColorScheme(); @@ -18,18 +31,23 @@ export default function RootLayout() { } return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ); } diff --git a/app/auth/login.tsx b/app/auth/login.tsx index a2fac81..f814a13 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -2,17 +2,21 @@ import { Ionicons } from '@expo/vector-icons'; import * as AppleAuthentication from 'expo-apple-authentication'; import { useRouter } from 'expo-router'; 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 { ThemedView } from '@/components/ThemedView'; import { Colors } from '@/constants/Colors'; +import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { login } from '@/store/userSlice'; export default function LoginScreen() { const router = useRouter(); const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const color = Colors[scheme]; + const dispatch = useAppDispatch(); const [hasAgreed, setHasAgreed] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false); @@ -40,11 +44,13 @@ export default function LoginScreen() { AppleAuthentication.AppleAuthenticationScope.EMAIL, ], }); - // TODO: 将 credential 发送到后端换取应用会话。这里先直接返回上一页。 + const identityToken = (credential as any)?.identityToken; + await dispatch(login({ appleIdentityToken: identityToken })).unwrap(); router.back(); } catch (err: any) { if (err?.code === 'ERR_CANCELED') return; - Alert.alert('登录失败', '请稍后再试'); + const message = err?.message || '登录失败,请稍后再试'; + Alert.alert('登录失败', message); } finally { setLoading(false); } @@ -58,7 +64,7 @@ export default function LoginScreen() { const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]); return ( - + {/* 自定义头部,与其它页面风格一致 */} @@ -71,7 +77,7 @@ export default function LoginScreen() { - Digital Pilates + 数字普拉提 欢迎登录 diff --git a/app/profile/goals.tsx b/app/profile/goals.tsx new file mode 100644 index 0000000..05f80ac --- /dev/null +++ b/app/profile/goals.tsx @@ -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(400); + const [steps, setSteps] = useState(8000); + const [purposes, setPurposes] = useState([]); + + 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 }) => ( + + {title} + {subtitle ? {subtitle} : null} + {children} + + ); + + const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }> + = ({ label, active, onPress }) => ( + + {label} + + ); + + const Stepper: React.FC<{ onDec: () => void; onInc: () => void }> + = ({ onDec, onInc }) => ( + + + - + + + + + + + ); + + 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 ( + + {/* Header(参照 AI 体态测评页面的实现) */} + + router.back()} + style={[styles.backButton, { backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }]} + > + + + 目标管理 + + + + + + + + {calories} kcal + changeWithHaptics(dec(calories, CALORIES_RANGE), setCalories)} + onInc={() => changeWithHaptics(inc(calories, CALORIES_RANGE), setCalories)} + /> + + + + {[200, 300, 400, 500, 600].map((v) => ( + changeWithHaptics(v, setCalories)} + /> + ))} + + 建议范围 {CALORIES_RANGE.min}-{CALORIES_RANGE.max} kcal,步进 {CALORIES_RANGE.step} + + + + + {steps.toLocaleString()} 步 + changeWithHaptics(dec(steps, STEPS_RANGE), setSteps)} + onInc={() => changeWithHaptics(inc(steps, STEPS_RANGE), setSteps)} + /> + + + + {[6000, 8000, 10000, 12000, 15000].map((v) => ( + changeWithHaptics(v, setSteps)} + /> + ))} + + 建议范围 {STEPS_RANGE.min.toLocaleString()}-{STEPS_RANGE.max.toLocaleString()},步进 {STEPS_RANGE.step} + + + + + {PURPOSE_OPTIONS.map((opt) => { + const active = purposes.includes(opt.id); + return ( + togglePurpose(opt.id)} + > + + + + + {opt.label} + + + ); + })} + + {purposes.length > 0 && ( + 已选择 {purposes.length} 项 + )} + + + + + ); +} + +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, + }, +}); + + diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx index ab9346e..e536fe1 100644 --- a/components/ProgressBar.tsx +++ b/components/ProgressBar.tsx @@ -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'; type ProgressBarProps = { @@ -11,7 +11,7 @@ type ProgressBarProps = { showLabel?: boolean; }; -export function ProgressBar({ +function ProgressBarImpl({ progress, height = 18, 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({ container: { width: '100%', diff --git a/constants/Api.ts b/constants/Api.ts new file mode 100644 index 0000000..20bb724 --- /dev/null +++ b/constants/Api.ts @@ -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}`; +} + + diff --git a/hooks/redux.ts b/hooks/redux.ts new file mode 100644 index 0000000..2678433 --- /dev/null +++ b/hooks/redux.ts @@ -0,0 +1,7 @@ +import type { AppDispatch, RootState } from '@/store'; +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; + + diff --git a/ios/digitalpilates/digitalpilates.entitlements b/ios/digitalpilates/digitalpilates.entitlements index c41dab6..4e75a27 100644 --- a/ios/digitalpilates/digitalpilates.entitlements +++ b/ios/digitalpilates/digitalpilates.entitlements @@ -9,6 +9,8 @@ com.apple.developer.healthkit com.apple.developer.healthkit.access - + + health-records + diff --git a/package-lock.json b/package-lock.json index 4af286c..26e3417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", + "@reduxjs/toolkit": "^2.8.2", "dayjs": "^1.11.13", "expo": "~53.0.20", "expo-apple-authentication": "6.4.2", @@ -40,7 +41,8 @@ "react-native-screens": "~4.11.1", "react-native-svg": "^15.12.1", "react-native-web": "~0.20.0", - "react-native-webview": "13.13.5" + "react-native-webview": "13.13.5", + "react-redux": "^9.2.0" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -3123,6 +3125,32 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3154,6 +3182,18 @@ "@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": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", @@ -3290,6 +3330,12 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "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": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -7442,6 +7488,16 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -10817,6 +10873,29 @@ "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": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -10826,6 +10905,21 @@ "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10981,6 +11075,12 @@ "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": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/package.json b/package.json index e19747d..033432b 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", + "@reduxjs/toolkit": "^2.8.2", "dayjs": "^1.11.13", "expo": "~53.0.20", - "expo-blur": "~14.1.5", "expo-apple-authentication": "6.4.2", + "expo-blur": "~14.1.5", "expo-constants": "~17.1.7", "expo-font": "~13.3.2", "expo-haptics": "~14.1.4", @@ -43,7 +44,8 @@ "react-native-screens": "~4.11.1", "react-native-svg": "^15.12.1", "react-native-web": "~0.20.0", - "react-native-webview": "13.13.5" + "react-native-webview": "13.13.5", + "react-redux": "^9.2.0" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -53,4 +55,4 @@ "typescript": "~5.8.3" }, "private": true -} \ No newline at end of file +} diff --git a/services/api.ts b/services/api.ts new file mode 100644 index 0000000..23190ac --- /dev/null +++ b/services/api.ts @@ -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 { + inMemoryToken = token; +} + +export function getAuthToken(): string | null { + return inMemoryToken; +} + +export type ApiRequestOptions = { + method?: HttpMethod; + headers?: Record; + body?: any; + signal?: AbortSignal; +}; + +export type ApiResponse = { + data: T; +}; + +async function doFetch(path: string, options: ApiRequestOptions = {}): Promise { + const url = buildApiUrl(path); + const headers: Record = { + '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: (path: string, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'GET' }), + post: (path: string, body?: any, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'POST', body }), + put: (path: string, body?: any, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'PUT', body }), + patch: (path: string, body?: any, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'PATCH', body }), + delete: (path: string, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'DELETE' }), +}; + +export const STORAGE_KEYS = { + authToken: '@auth_token', + userProfile: '@user_profile', +} as const; + +export async function loadPersistedToken(): Promise { + try { + const t = await AsyncStorage.getItem(STORAGE_KEYS.authToken); + return t || null; + } catch { + return null; + } +} + + diff --git a/store/index.ts b/store/index.ts new file mode 100644 index 0000000..81df940 --- /dev/null +++ b/store/index.ts @@ -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; +export type AppDispatch = typeof store.dispatch; + + diff --git a/store/userSlice.ts b/store/userSlice.ts new file mode 100644 index 0000000..c5c99fd --- /dev/null +++ b/store/userSlice.ts @@ -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 & { + // 可扩展:用户名密码、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>) { + 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; + +