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 { 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 */}
|
||||
<SearchBox placeholder="搜索" />
|
||||
|
||||
{/* Popular Workouts Section */}
|
||||
{/* Hot Features Section */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<ThemedText style={styles.sectionTitle}>热门活动</ThemedText>
|
||||
<ThemedText style={styles.sectionTitle}>热点功能</ThemedText>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.workoutScrollContainer}
|
||||
style={styles.workoutScroll}
|
||||
>
|
||||
{workoutData.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
title={workout.title}
|
||||
duration={workout.duration}
|
||||
imageSource={workout.imageSource}
|
||||
onPress={() => {
|
||||
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}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View style={styles.featureGrid}>
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardPrimary]}
|
||||
onPress={() => router.push('/ai-posture-assessment')}
|
||||
>
|
||||
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
|
||||
<ThemedText style={styles.featureSubtitle}>3分钟获取体态报告</ThemedText>
|
||||
<View style={styles.featureCta}>
|
||||
<ThemedText style={styles.featureCtaText}>开始评估</ThemedText>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardSecondary]}
|
||||
onPress={() => router.push('/health-consultation' as any)}
|
||||
>
|
||||
<ThemedText style={styles.featureTitle}>在线教练</ThemedText>
|
||||
<ThemedText style={styles.featureSubtitle}>认证教练 · 1对1即时解答</ThemedText>
|
||||
<View style={styles.featureCta}>
|
||||
<ThemedText style={styles.featureCtaText}>立即咨询</ThemedText>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 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,
|
||||
},
|
||||
|
||||
@@ -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<UserProfile>({});
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="ai-posture-assessment" />
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
<Provider store={store}>
|
||||
<Bootstrapper>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="ai-posture-assessment" />
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||
<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 { 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<boolean>(false);
|
||||
const [appleAvailable, setAppleAvailable] = useState<boolean>(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 (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: color.background }]}>
|
||||
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: color.background }]}>
|
||||
<ThemedView style={styles.container}>
|
||||
{/* 自定义头部,与其它页面风格一致 */}
|
||||
<View style={styles.header}>
|
||||
@@ -71,7 +77,7 @@ export default function LoginScreen() {
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<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>
|
||||
</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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user