diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index a436208..746d108 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -3,6 +3,7 @@ import { CircularRing } from '@/components/CircularRing'; import { ProgressBar } from '@/components/ProgressBar'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; +import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health'; @@ -23,6 +24,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function ExploreScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; + const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; // 使用 dayjs:当月日期与默认选中“今天” const days = getMonthDaysZh(); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); @@ -185,13 +187,13 @@ export default function ExploreScreen() { value={stepCount} resetToken={animToken} style={styles.stepsValue} - format={(v) => `${Math.round(v)}/2000`} + format={(v) => `${Math.round(v)}/${stepGoal}`} /> ) : ( - ——/2000 + ——/{stepGoal} )} Math.max(min, Math.min(max, value)); + + const panResponder = React.useMemo(() => PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (_evt, gesture) => Math.abs(gesture.dx) + Math.abs(gesture.dy) > 2, + onPanResponderGrant: () => { + dragState.current.moved = false; + // @ts-ignore access current value + const currentX = (pan.x as any)._value ?? 0; + // @ts-ignore access current value + const currentY = (pan.y as any)._value ?? 0; + startRef.current = { x: currentX, y: currentY }; + }, + onPanResponderMove: (_evt, gesture) => { + if (!dragState.current.moved && (Math.abs(gesture.dx) + Math.abs(gesture.dy) > 4)) { + dragState.current.moved = true; + } + const nextX = startRef.current.x + gesture.dx; + const nextY = startRef.current.y + gesture.dy; + pan.setValue({ x: nextX, y: nextY }); + }, + onPanResponderRelease: (_evt, gesture) => { + const minX = 8; + const minY = insets.top + 2; + const maxX = Math.max(minX, windowWidth - coachSize.width - 8); + const maxY = Math.max(minY, windowHeight - coachSize.height - (insets.bottom + 8)); + const rawX = startRef.current.x + gesture.dx; + const rawY = startRef.current.y + gesture.dy; + const clampedX = clamp(rawX, minX, maxX); + const clampedY = clamp(rawY, minY, maxY); + // Snap horizontally to nearest side (left/right only) + const distLeft = Math.abs(clampedX - minX); + const distRight = Math.abs(maxX - clampedX); + const snapX = distLeft <= distRight ? minX : maxX; + Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, bounciness: 6 }).start(() => { + if (!dragState.current.moved) { + // Treat as tap + // @ts-ignore - expo-router string ok + router.push('/ai-coach-chat?name=Iris' as any); + } + }); + }, + }), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]); return ( + {/* Floating Coach Badge */} + + { + const { width, height } = e.nativeEvent.layout; + if (width !== coachSize.width || height !== coachSize.height) { + setCoachSize({ width, height }); + } + if (!hasInitPos.current && width > 0 && windowWidth > 0) { + const initX = windowWidth - width - 14; + const initY = insets.top + 2; // 默认更靠上,避免遮挡搜索框 + pan.setValue({ x: initX, y: initY }); + hasInitPos.current = true; + } + }} + style={[ + styles.coachBadge, + { + transform: [{ translateX: pan.x }, { translateY: pan.y }], + backgroundColor: colorTokens.heroSurfaceTint, + borderColor: 'rgba(187,242,70,0.35)', + shadowColor: '#000', + shadowOpacity: 0.08, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 3, + position: 'absolute', + left: 0, + top: 0, + }, + ]} + > + + + Iris + + + 在线 + + + + {/* Header Section */} @@ -66,7 +166,7 @@ export default function HomeScreen() { @@ -92,7 +191,7 @@ export default function HomeScreen() { pushIfAuthedElseLogin('/checkin')}> ( - + {/* 头像 */} - - - {/* 简单的头像图标,您可以替换为实际图片 */} - - - - - + + {/* 用户信息 */} - {profile.fullName || '未设置姓名'} + {displayName} {/* 编辑按钮 */} @@ -147,25 +145,25 @@ export default function PersonalScreen() { ); const StatsSection = () => ( - + {formatHeight()} - 身高 + 身高 {formatWeight()} - 体重 + 体重 {formatAge()} - 年龄 + 年龄 ); const MenuSection = ({ title, items }: { title: string; items: any[] }) => ( - - {title} + + {title} {items.map((item, index) => ( - - + + - {item.title} + {item.title} {item.type === 'switch' ? ( ) : ( - + )} ))} @@ -290,7 +288,7 @@ export default function PersonalScreen() { return ( - + (); @@ -47,11 +51,13 @@ export default function AICoachChatScreen() { const listRef = useRef>(null); const planDraft = useAppSelector((s) => s.trainingPlan?.draft); + const checkin = useAppSelector((s) => (s as any).checkin); const chips = useMemo(() => [ { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') }, { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() }, - ], [router, planDraft]); + { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() }, + ], [router, planDraft, checkin]); function scrollToEnd() { requestAnimationFrame(() => { @@ -109,6 +115,45 @@ export default function AICoachChatScreen() { send(prompt); } + function buildTrainingSummary(): string { + const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[]; + if (!entries.length) return ''; + const recent = entries.sort((a: any, b: any) => String(b.date).localeCompare(String(a.date))).slice(0, 14); + let totalSessions = 0; + let totalExercises = 0; + let totalCompleted = 0; + const categoryCount: Record = {}; + const exerciseCount: Record = {}; + for (const rec of recent) { + if (!rec?.items?.length) continue; + totalSessions += 1; + for (const it of rec.items) { + totalExercises += 1; + if (it.completed) totalCompleted += 1; + categoryCount[it.category] = (categoryCount[it.category] || 0) + 1; + exerciseCount[it.name] = (exerciseCount[it.name] || 0) + 1; + } + } + const topCategories = Object.entries(categoryCount).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k}×${v}`); + const topExercises = Object.entries(exerciseCount).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k}×${v}`); + return [ + `统计周期:最近${recent.length}天(按有记录日计 ${totalSessions} 天)`, + `记录条目:${totalExercises},完成标记:${totalCompleted}`, + topCategories.length ? `高频类别:${topCategories.join(',')}` : '', + topExercises.length ? `高频动作:${topExercises.join(',')}` : '', + ].filter(Boolean).join('\n'); + } + + function handleAnalyzeRecords() { + const summary = buildTrainingSummary(); + if (!summary) { + send('我还没有可分析的打卡记录,请先在“每日打卡”添加并完成一些训练记录,然后帮我分析近期训练表现与改进建议。'); + return; + } + const prompt = `请基于以下我的近期训练记录进行分析,输出:1)整体训练负荷与节奏;2)动作与肌群的均衡性(指出偏多/偏少);3)容易忽视的恢复与热身建议;4)后续一周的优化建议(频次/时长/动作方向)。\n\n${summary}`; + send(prompt); + } + function renderItem({ item }: { item: ChatMessage }) { const isUser = item.role === 'user'; return ( @@ -118,9 +163,7 @@ export default function AICoachChatScreen() { style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]} > {!isUser && ( - - AI - + )} - {profile.avatarUri ? ( - - ) : ( - - )} + diff --git a/app/profile/goals.tsx b/app/profile/goals.tsx index 8b7d895..238a441 100644 --- a/app/profile/goals.tsx +++ b/app/profile/goals.tsx @@ -17,6 +17,8 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ProgressBar } from '@/components/ProgressBar'; +import { useAppDispatch } from '@/hooks/redux'; +import { setDailyCaloriesGoal, setDailyStepsGoal, setPilatesPurposes } from '@/store/userSlice'; const STORAGE_KEYS = { calories: '@goal_calories_burn', @@ -32,6 +34,7 @@ export default function GoalsScreen() { const insets = useSafeAreaInsets(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colors = Colors[theme]; + const dispatch = useAppDispatch(); const [calories, setCalories] = useState(400); const [steps, setSteps] = useState(8000); @@ -66,14 +69,17 @@ export default function GoalsScreen() { useEffect(() => { AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { }); + dispatch(setDailyCaloriesGoal(calories)); }, [calories]); useEffect(() => { AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { }); + dispatch(setDailyStepsGoal(steps)); }, [steps]); useEffect(() => { AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { }); + dispatch(setPilatesPurposes(purposes)); }, [purposes]); const caloriesPercent = useMemo(() => @@ -154,9 +160,8 @@ export default function GoalsScreen() { return ( - router.back()} withSafeTop={false} tone={theme} transparent /> - + router.back()} withSafeTop={false} tone={theme} transparent /> diff --git a/components/PlanCard.tsx b/components/PlanCard.tsx index 1d208aa..5265a8e 100644 --- a/components/PlanCard.tsx +++ b/components/PlanCard.tsx @@ -8,7 +8,7 @@ type PlanCardProps = { image: string; title: string; subtitle: string; - level: Level; + level?: Level; progress: number; // 0 - 1 }; @@ -17,11 +17,13 @@ export function PlanCard({ image, title, subtitle, level, progress }: PlanCardPr - - - {level} + {level && ( + + + {level} + - + )} {title} {subtitle} diff --git a/store/userSlice.ts b/store/userSlice.ts index c5c99fd..078532d 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -12,6 +12,9 @@ export type UserProfile = { weightKg?: number; heightCm?: number; avatarUri?: string | null; + dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等) + dailyCaloriesGoal?: number; // 每日卡路里消耗目标 + pilatesPurposes?: string[]; // 普拉提目的(多选) }; export type UserState = { @@ -21,10 +24,12 @@ export type UserState = { error: string | null; }; +export const DEFAULT_MEMBER_NAME = '普拉提星球学员'; + const initialState: UserState = { token: null, profile: { - + fullName: DEFAULT_MEMBER_NAME, }, loading: false, error: null, @@ -94,6 +99,15 @@ const userSlice = createSlice({ updateProfile(state, action: PayloadAction>) { state.profile = { ...(state.profile ?? {}), ...action.payload }; }, + setDailyStepsGoal(state, action: PayloadAction) { + state.profile = { ...(state.profile ?? {}), dailyStepsGoal: action.payload }; + }, + setDailyCaloriesGoal(state, action: PayloadAction) { + state.profile = { ...(state.profile ?? {}), dailyCaloriesGoal: action.payload }; + }, + setPilatesPurposes(state, action: PayloadAction) { + state.profile = { ...(state.profile ?? {}), pilatesPurposes: action.payload }; + }, }, extraReducers: (builder) => { builder @@ -102,6 +116,9 @@ const userSlice = createSlice({ state.loading = false; state.token = action.payload.token; state.profile = action.payload.profile; + if (!state.profile?.fullName || !state.profile.fullName.trim()) { + state.profile.fullName = DEFAULT_MEMBER_NAME; + } }) .addCase(login.rejected, (state, action) => { state.loading = false; @@ -110,6 +127,9 @@ const userSlice = createSlice({ .addCase(rehydrateUser.fulfilled, (state, action) => { state.token = action.payload.token; state.profile = action.payload.profile; + if (!state.profile?.fullName || !state.profile.fullName.trim()) { + state.profile.fullName = DEFAULT_MEMBER_NAME; + } }) .addCase(logout.fulfilled, (state) => { state.token = null; @@ -118,7 +138,7 @@ const userSlice = createSlice({ }, }); -export const { updateProfile } = userSlice.actions; +export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions; export default userSlice.reducer;