feat: 更新应用名称和图标,优化用户界面

- 将应用名称修改为“Health Bot”,提升品牌识别度
- 更新应用图标为 logo.png,确保视觉一致性
- 删除不再使用的 ai-coach-chat 页面,简化代码结构
- 更新多个页面的导航和按钮文本,提升用户体验
- 添加体重历史记录功能,支持用户追踪健康数据
- 优化 Redux 状态管理,确保数据处理的准确性和稳定性
This commit is contained in:
2025-08-17 21:34:04 +08:00
parent b2c4f76c39
commit 6a67fb21f7
25 changed files with 1511 additions and 1428 deletions

View File

@@ -8,16 +8,18 @@ import {
import { Ionicons } from '@expo/vector-icons';
import React, { useState } from 'react';
import {
Dimensions,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
View
} from 'react-native';
import Toast from 'react-native-toast-message';
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
interface BMICardProps {
weight?: number;
height?: number;
@@ -31,8 +33,6 @@ export function BMICard({ weight, height, style }: BMICardProps) {
const canCalculate = canCalculateBMI(weight, height);
let bmiResult: BMIResult | null = null;
if (canCalculate && weight && height) {
try {
bmiResult = getBMIResult(weight, height);
@@ -156,56 +156,88 @@ export function BMICard({ weight, height, style }: BMICardProps) {
style={styles.modalBackdrop}
onPress={handleHideInfoModal}
>
<Pressable style={styles.modalContainer} onPress={(e) => e.stopPropagation()}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Pressable
style={styles.modalContainer}
onPress={(e) => e.stopPropagation()}
>
{/* 弹窗头部 */}
<View style={styles.modalHeader}>
<View style={styles.modalTitleContainer}>
<View style={styles.modalIconContainer}>
<Ionicons name="fitness" size={24} color="#3B82F6" />
</View>
<Text style={styles.modalTitle}>BMI </Text>
<TouchableOpacity
onPress={handleHideInfoModal}
style={styles.closeButton}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleHideInfoModal}
style={styles.closeButton}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
{/* 内容区域 - 去除滚动,精简设计 */}
<View style={styles.modalContent}>
{/* 介绍部分 */}
<View style={styles.introSection}>
<Text style={styles.modalDescription}>
BMI
</Text>
<View style={styles.formulaContainer}>
<Text style={styles.formulaText}>
(kg) ÷ ²(m)
</Text>
</View>
</View>
<ScrollView
style={styles.modalBody}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20 }}
>
<Text style={styles.modalDescription}>
BMI(kg) ÷ ²(m)
</Text>
{/* BMI 分类标准 - 紧凑设计 */}
<View style={styles.categoriesSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.categoriesGrid}>
{BMI_CATEGORIES.map((category, index) => {
const colors = index === 0 ? { bg: '#FEF3C7', text: '#B45309' } :
index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } :
index === 2 ? { bg: '#FEF3C7', text: '#B45309' } :
{ bg: '#FEE2E2', text: '#B91C1C' };
<Text style={styles.sectionTitle}>BMI </Text>
{BMI_CATEGORIES.map((category, index) => {
const colors = index === 0 ? { bg: '#FFF4E6', text: '#8B7355' } :
index === 1 ? { bg: '#E8F5E8', text: '#2D5016' } :
index === 2 ? { bg: '#FEF3C7', text: '#B45309' } :
{ bg: '#FEE2E2', text: '#B91C1C' };
return (
<View key={index} style={[styles.categoryItem, { backgroundColor: colors.bg }]}>
<View style={styles.categoryHeader}>
<Text style={[styles.categoryName, { color: colors.text }]}>
{category.name}
</Text>
<Text style={[styles.categoryRange, { color: colors.text }]}>
{category.range}
return (
<View key={index} style={[styles.categoryCompact, { backgroundColor: colors.bg }]}>
<View style={styles.categoryCompactHeader}>
<Text style={[styles.categoryCompactName, { color: colors.text }]}>
{category.name}
</Text>
<Text style={[styles.categoryCompactRange, { color: colors.text }]}>
{category.range}
</Text>
</View>
<Text style={[styles.categoryCompactAdvice, { color: colors.text }]} numberOfLines={2}>
{category.advice}
</Text>
</View>
<Text style={styles.categoryAdvice}>
{category.advice}
</Text>
</View>
);
})}
);
})}
</View>
</View>
<Text style={styles.disclaimer}>
* BMI
{/* 健康提示 - 简化版 */}
<View style={styles.healthTips}>
<View style={styles.tipsHeader}>
<Ionicons name="heart" size={16} color="#EF4444" />
<Text style={styles.tipsTitle}></Text>
</View>
<Text style={styles.tipsContent}>
</Text>
</ScrollView>
</View>
{/* 免责声明 - 精简版 */}
<View style={styles.disclaimerCompact}>
<Ionicons name="information-circle" size={14} color="#6B7280" />
<Text style={styles.disclaimerCompactText}>
BMI
</Text>
</View>
</View>
</Pressable>
</Pressable>
@@ -336,8 +368,7 @@ const styles = StyleSheet.create({
padding: 20,
},
modalContainer: {
width: '90%',
maxHeight: '85%',
width: screenWidth * 0.92,
backgroundColor: '#FFFFFF',
borderRadius: 24,
overflow: 'hidden',
@@ -347,98 +378,161 @@ const styles = StyleSheet.create({
shadowOpacity: 0.3,
shadowRadius: 20,
},
modalContent: {
maxHeight: '100%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 24,
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
backgroundColor: '#FAFAFA',
},
modalTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
modalIconContainer: {
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: '#EFF6FF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
modalTitle: {
fontSize: 22,
fontSize: 20,
fontWeight: '800',
color: '#111827',
letterSpacing: -0.5,
},
closeButton: {
padding: 8,
borderRadius: 20,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
},
modalBody: {
paddingHorizontal: 24,
paddingVertical: 20,
// 内容区域样式
modalContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
// 介绍部分
introSection: {
marginBottom: 20,
},
modalDescription: {
fontSize: 16,
color: '#4B5563',
lineHeight: 26,
marginBottom: 28,
textAlign: 'center',
backgroundColor: '#F8FAFC',
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: '#3B82F6',
},
sectionTitle: {
fontSize: 20,
fontWeight: '800',
color: '#111827',
marginBottom: 16,
letterSpacing: -0.3,
},
categoryItem: {
borderRadius: 16,
padding: 20,
marginBottom: 16,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
categoryHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
categoryName: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.2,
},
categoryRange: {
fontSize: 15,
fontWeight: '700',
backgroundColor: 'rgba(255,255,255,0.8)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 20,
},
categoryAdvice: {
fontSize: 15,
color: '#374151',
lineHeight: 22,
fontWeight: '500',
textAlign: 'center',
marginBottom: 12,
},
disclaimer: {
formulaContainer: {
backgroundColor: '#F8FAFC',
borderRadius: 12,
padding: 16,
borderLeftWidth: 4,
borderLeftColor: '#3B82F6',
},
formulaText: {
fontSize: 15,
color: '#1F2937',
fontWeight: '600',
textAlign: 'center',
},
// 分类部分
categoriesSection: {
marginBottom: 18,
},
sectionTitle: {
fontSize: 16,
fontWeight: '800',
color: '#111827',
marginBottom: 12,
textAlign: 'center',
},
categoriesGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
categoryCompact: {
flex: 1,
minWidth: '48%',
borderRadius: 12,
padding: 12,
marginBottom: 8,
},
categoryCompactHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
categoryCompactName: {
fontSize: 14,
fontWeight: '700',
},
categoryCompactRange: {
fontSize: 12,
fontWeight: '600',
opacity: 0.8,
},
categoryCompactAdvice: {
fontSize: 11,
lineHeight: 16,
fontWeight: '500',
opacity: 0.9,
},
// 健康提示
healthTips: {
backgroundColor: '#F9FAFB',
borderRadius: 12,
padding: 14,
marginBottom: 16,
borderLeftWidth: 3,
borderLeftColor: '#EF4444',
},
tipsHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
tipsTitle: {
fontSize: 14,
fontWeight: '700',
color: '#111827',
marginLeft: 6,
},
tipsContent: {
fontSize: 13,
color: '#374151',
lineHeight: 18,
},
// 免责声明紧凑版
disclaimerCompact: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F9FAFB',
borderRadius: 8,
padding: 12,
marginTop: 4,
},
disclaimerCompactText: {
fontSize: 12,
color: '#6B7280',
fontStyle: 'italic',
marginTop: 24,
textAlign: 'center',
backgroundColor: '#F9FAFB',
padding: 16,
borderRadius: 12,
lineHeight: 20,
lineHeight: 16,
marginLeft: 6,
flex: 1,
},
});

View File

@@ -0,0 +1,487 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { fetchWeightHistory } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = screenWidth - 40; // 减去左右边距
const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距
const CHART_HEIGHT = 100;
const PADDING = 10;
type WeightHistoryItem = {
weight: string;
source: string;
createdAt: string;
};
export function WeightHistoryCard() {
const router = useRouter();
const dispatch = useAppDispatch();
const userProfile = useAppSelector((s) => s.user.profile);
const weightHistory = useAppSelector((s) => s.user.weightHistory);
const [isLoading, setIsLoading] = useState(false);
const [showChart, setShowChart] = useState(false);
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
useEffect(() => {
if (hasWeight) {
loadWeightHistory();
}
}, [hasWeight]);
const loadWeightHistory = async () => {
try {
setIsLoading(true);
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error('加载体重历史失败:', error);
} finally {
setIsLoading(false);
}
};
const navigateToCoach = () => {
router.push('/(tabs)/coach');
};
// 如果正在加载,显示加载状态
if (isLoading) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>...</Text>
</View>
</View>
);
}
// 如果没有体重数据,显示引导卡片
if (!hasWeight) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<View style={styles.emptyIconContainer}>
<Ionicons name="scale-outline" size={32} color="#BBF246" />
</View>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
// 处理体重历史数据
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7); // 只显示最近7条记录
if (sortedHistory.length === 0) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
// 生成图表数据
const weights = sortedHistory.map(item => parseFloat(item.weight));
const minWeight = Math.min(...weights);
const maxWeight = Math.max(...weights);
const weightRange = maxWeight - minWeight || 1;
const points = sortedHistory.map((item, index) => {
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
// 减少顶部边距,压缩留白
const y = PADDING + 15 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 30);
return { x, y, weight: item.weight, date: item.createdAt };
});
// 生成路径
const pathData = points.map((point, index) => {
if (index === 0) return `M ${point.x} ${point.y}`;
return `L ${point.x} ${point.y}`;
}).join(' ');
// 如果只有一个数据点,显示为水平线
const singlePointPath = points.length === 1 ?
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
pathData;
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.chartToggleButton}
onPress={() => setShowChart(!showChart)}
activeOpacity={0.8}
>
<Ionicons
name={showChart ? "chevron-up" : "chevron-down"}
size={16}
color="#BBF246"
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={16} color="#BBF246" />
</TouchableOpacity>
</View>
</View>
{/* 默认信息显示 */}
{!showChart && sortedHistory.length > 0 && (
<View style={styles.summaryInfo}>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>{userProfile.weight}kg</Text>
</View>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>{sortedHistory.length}</Text>
</View>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
</View>
<TouchableOpacity
style={styles.viewTrendButton}
onPress={() => setShowChart(true)}
activeOpacity={0.8}
>
<Ionicons name="trending-up" size={14} color="#BBF246" />
<Text style={styles.viewTrendText}></Text>
</TouchableOpacity>
</View>
)}
{/* 图表容器 - 可折叠 */}
{showChart && (
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
{/* 背景网格线 */}
{[0, 1, 2, 3, 4].map(i => (
<Line
key={`grid-${i}`}
x1={PADDING}
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
x2={CHART_WIDTH - PADDING}
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
stroke="#F0F0F0"
strokeWidth={1}
/>
))}
{/* 折线 */}
<Path
d={singlePointPath}
stroke="#BBF246"
strokeWidth={3}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 数据点和标签 */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
const isFirstPoint = index === 0;
const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1;
return (
<React.Fragment key={index}>
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 6 : 4}
fill="#BBF246"
stroke="#FFFFFF"
strokeWidth={2}
/>
{/* 体重标签 - 只在关键点显示 */}
{showLabel && (
<>
<Circle
cx={point.x}
cy={point.y - 15}
r={10}
fill="rgba(255,255,255,0.9)"
stroke="#BBF246"
strokeWidth={1}
/>
<SvgText
x={point.x}
y={point.y - 12}
fontSize="9"
fill="#192126"
textAnchor="middle"
>
{point.weight}
</SvgText>
</>
)}
</React.Fragment>
);
})}
</Svg>
{/* 图表底部信息 */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{sortedHistory.length}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
</View>
{/* 最近记录时间 */}
{sortedHistory.length > 0 && (
<Text style={styles.lastRecordText}>
{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
</Text>
)}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
iconSquare: {
width: 30,
height: 30,
borderRadius: 8,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
flex: 1,
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
chartToggleButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
},
emptyContent: {
alignItems: 'center',
paddingVertical: 20,
},
emptyIconContainer: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
},
emptyTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 6,
},
emptyDescription: {
fontSize: 14,
color: '#687076',
textAlign: 'center',
marginBottom: 16,
lineHeight: 20,
},
recordButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#BBF246',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
gap: 6,
},
recordButtonText: {
color: '#192126',
fontSize: 14,
fontWeight: '700',
},
chartContainer: {
alignItems: 'center',
minHeight: 100,
},
chartInfo: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#F0F0F0',
},
infoItem: {
alignItems: 'center',
},
infoLabel: {
fontSize: 12,
color: '#687076',
marginBottom: 4,
},
infoValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
lastRecordText: {
fontSize: 12,
color: '#687076',
textAlign: 'center',
marginTop: 8,
},
summaryInfo: {
paddingVertical: 12,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 12,
},
summaryItem: {
alignItems: 'center',
},
summaryLabel: {
fontSize: 12,
color: '#687076',
marginBottom: 4,
},
summaryValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
viewTrendButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F0F8E0',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
gap: 6,
alignSelf: 'center',
},
viewTrendText: {
fontSize: 13,
fontWeight: '600',
color: '#192126',
},
});