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;
+
+