feat: 更新 AI 教练聊天界面和个人信息页面

- 在 AI 教练聊天界面中添加训练记录分析功能,允许用户基于近期训练记录获取分析建议
- 更新 Redux 状态管理,集成每日步数和卡路里目标
- 在个人信息页面中优化用户头像显示,支持从库中选择头像
- 修改首页布局,添加可拖动的教练徽章,提升用户交互体验
- 更新样式以适应新功能的展示和交互
This commit is contained in:
richarjiang
2025-08-13 10:09:55 +08:00
parent f3e6250505
commit 5814044cee
8 changed files with 278 additions and 54 deletions

View File

@@ -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}`}
/>
) : (
<Text style={styles.stepsValue}>/2000</Text>
<Text style={styles.stepsValue}>/{stepGoal}</Text>
)}
<ProgressBar
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / 2000))}
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / stepGoal))}
height={18}
trackColor="#FFEBCB"
fillColor="#FFC365"

View File

@@ -9,7 +9,8 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
import { getChineseGreeting } from '@/utils/date';
import { useRouter } from 'expo-router';
import React from 'react';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
@@ -18,9 +19,108 @@ export default function HomeScreen() {
const { pushIfAuthedElseLogin } = useAuthGuard();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// Draggable coach badge state
const pan = React.useRef(new Animated.ValueXY()).current;
const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 });
const hasInitPos = React.useRef(false);
const startRef = React.useRef({ x: 0, y: 0 });
const dragState = React.useRef({ moved: false });
const clamp = (value: number, min: number, max: number) => 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 (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
{/* Floating Coach Badge */}
<View pointerEvents="box-none" style={styles.coachOverlayWrap}>
<Animated.View
{...panResponder.panHandlers}
onLayout={(e) => {
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,
},
]}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg' }}
style={styles.coachAvatar}
/>
<View style={styles.coachMeta}>
<ThemedText style={styles.coachName}>Iris</ThemedText>
<View style={styles.coachStatusRow}>
<View style={styles.statusDot} />
<ThemedText style={styles.coachStatusText}>线</ThemedText>
</View>
</View>
</Animated.View>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header Section */}
<View style={styles.header}>
@@ -66,7 +166,7 @@ export default function HomeScreen() {
<View style={styles.planList}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png'}
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg'}
title="体态评估"
subtitle="评估你的体态,制定训练计划"
level="初学者"
@@ -77,7 +177,6 @@ export default function HomeScreen() {
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
title="每周打卡"
subtitle="养成训练习惯,练出好身材"
level="初学者"
progress={0.75}
/>
</Pressable>
@@ -92,7 +191,7 @@ export default function HomeScreen() {
</Pressable>
<Pressable onPress={() => pushIfAuthedElseLogin('/checkin')}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/ImageCheck.jpeg'}
title="每日打卡(自选动作)"
subtitle="选择动作,设置组数/次数,记录完成"
level="初学者"
@@ -126,6 +225,14 @@ const styles = StyleSheet.create({
paddingTop: 16,
paddingBottom: 8,
},
coachOverlayWrap: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 10,
},
greeting: {
fontSize: 16,
color: '#8A8A8E',
@@ -138,6 +245,45 @@ const styles = StyleSheet.create({
color: '#1A1A1A',
lineHeight: 36,
},
coachBadge: {
flexDirection: 'row',
alignItems: 'center',
// RN 不完全支持 gap这里用 margin 实现
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
backgroundColor: '#FFFFFF00',
},
coachAvatar: {
width: 26,
height: 26,
borderRadius: 13,
},
coachMeta: {
marginLeft: 8,
},
coachName: {
fontSize: 13,
fontWeight: '700',
color: '#192126',
},
coachStatusRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#22C55E',
marginRight: 4,
},
coachStatusText: {
fontSize: 11,
color: '#6B7280',
},
sectionContainer: {
marginTop: 24,
},

View File

@@ -2,6 +2,7 @@ import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
@@ -9,7 +10,7 @@ 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, Image, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function PersonalScreen() {
@@ -24,6 +25,7 @@ export default function PersonalScreen() {
const colors = Colors[colorScheme ?? 'light'];
const theme = (colorScheme ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
type UserProfile = {
fullName?: string;
@@ -117,25 +119,21 @@ export default function PersonalScreen() {
};
const displayName = (profile.fullName && profile.fullName.trim()) ? profile.fullName : DEFAULT_MEMBER_NAME;
const UserInfoSection = () => (
<View style={styles.userInfoCard}>
<View style={[styles.userInfoCard, { backgroundColor: colorTokens.card }]}>
<View style={styles.userInfoContainer}>
{/* 头像 */}
<View style={styles.avatarContainer}>
<View style={styles.avatar}>
<View style={styles.avatarContent}>
{/* 简单的头像图标,您可以替换为实际图片 */}
<View style={styles.avatarIcon}>
<View style={styles.avatarFace} />
<View style={styles.avatarBody} />
</View>
</View>
<View style={[styles.avatar, { backgroundColor: colorTokens.ornamentAccent }]}>
<Image source={{ uri: profile.avatarUri || DEFAULT_AVATAR_URL }} style={{ width: '100%', height: '100%' }} />
</View>
</View>
{/* 用户信息 */}
<View style={styles.userDetails}>
<Text style={styles.userName}>{profile.fullName || '未设置姓名'}</Text>
<Text style={[styles.userName, { color: colorTokens.text }]}>{displayName}</Text>
</View>
{/* 编辑按钮 */}
@@ -147,25 +145,25 @@ export default function PersonalScreen() {
);
const StatsSection = () => (
<View style={styles.statsContainer}>
<View style={[styles.statsContainer, { backgroundColor: colorTokens.card }]}>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text>
<Text style={[styles.statLabel, { color: colorTokens.textMuted }]}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text>
<Text style={[styles.statLabel, { color: colorTokens.textMuted }]}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text>
<Text style={[styles.statLabel, { color: colorTokens.textMuted }]}></Text>
</View>
</View>
);
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
<View style={styles.menuSection}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={[styles.menuSection, { backgroundColor: colorTokens.card }]}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>{title}</Text>
{items.map((item, index) => (
<TouchableOpacity
key={index}
@@ -173,10 +171,10 @@ export default function PersonalScreen() {
onPress={item.onPress}
>
<View style={styles.menuItemLeft}>
<View style={[styles.menuIcon]}>
<Ionicons name={item.icon} size={20} color={item.iconColor || colors.primary} />
<View style={[styles.menuIcon, { backgroundColor: 'rgba(187,242,70,0.12)' }]}>
<Ionicons name={item.icon} size={20} color={'#192126'} />
</View>
<Text style={styles.menuItemText}>{item.title}</Text>
<Text style={[styles.menuItemText, { color: colorTokens.text }]}>{item.title}</Text>
</View>
{item.type === 'switch' ? (
<Switch
@@ -187,7 +185,7 @@ export default function PersonalScreen() {
style={styles.switch}
/>
) : (
<Ionicons name="chevron-forward" size={20} color="#C4C4C4" />
<Ionicons name="chevron-forward" size={20} color={colorTokens.icon} />
)}
</TouchableOpacity>
))}
@@ -290,7 +288,7 @@ export default function PersonalScreen() {
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<StatusBar barStyle={theme === 'light' ? 'dark-content' : 'light-content'} backgroundColor="transparent" translucent />
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ScrollView
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}
@@ -333,6 +331,15 @@ const styles = StyleSheet.create({
userInfoCard: {
borderRadius: 16,
marginBottom: 20,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 3,
},
userInfoContainer: {
flexDirection: 'row',
@@ -380,7 +387,7 @@ const styles = StyleSheet.create({
userName: {
fontSize: 18,
fontWeight: 'bold',
color: '#000',
color: '#192126',
marginBottom: 4,
},
@@ -408,18 +415,19 @@ const styles = StyleSheet.create({
statLabel: {
fontSize: 12,
color: '#888',
color: '#687076',
},
// 菜单区域
menuSection: {
marginBottom: 20,
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#000',
fontSize: 20,
fontWeight: '800',
color: '#192126',
marginBottom: 12,
paddingHorizontal: 4,
},
@@ -447,7 +455,7 @@ const styles = StyleSheet.create({
},
menuItemText: {
fontSize: 16,
color: '#000',
color: '#192126',
flex: 1,
},
switch: {