feat: 集成 Redux 状态管理和用户目标管理功能
- 添加 Redux 状态管理,支持用户登录和个人信息的持久化 - 新增目标管理页面,允许用户设置每日卡路里和步数目标 - 更新首页,移除旧的活动展示,改为固定的热点功能卡片 - 修改布局以适应新功能的展示和交互 - 更新依赖,添加 @reduxjs/toolkit 和 react-redux 库以支持状态管理 - 新增 API 服务模块,处理与后端的交互
This commit is contained in:
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