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;