feat: 更新依赖项并优化组件结构

- 在 package.json 和 package-lock.json 中新增 @sentry/react-native、react-native-device-info 和 react-native-purchases 依赖
- 更新统计页面,替换 CircularRing 组件为 FitnessRingsCard,增强健身数据展示
- 在布局文件中引入 ToastProvider,优化用户通知体验
- 新增 SuccessToast 组件,提供全局成功提示功能
- 更新健康数据获取逻辑,支持健身圆环数据的提取
- 优化多个组件的样式和交互,提升用户体验
This commit is contained in:
richarjiang
2025-08-21 09:51:25 +08:00
parent 19b92547e1
commit 78620f18ee
21 changed files with 2494 additions and 108 deletions

View File

@@ -128,9 +128,6 @@ export function BMICard({ weight, height, style, compact = false }: BMICardProps
>
<View style={styles.cardHeader}>
<View style={styles.titleRow}>
<View style={styles.iconSquare}>
<Ionicons name="fitness-outline" size={16} color="#192126" />
</View>
<Text style={[styles.cardTitle, compact && styles.compactTitle]}>BMI</Text>
</View>
{!compact && (
@@ -219,7 +216,7 @@ export function BMICard({ weight, height, style, compact = false }: BMICardProps
{/* BMI 分类标准 */}
<Text style={styles.newSectionTitle}>BMI </Text>
<View style={styles.newStatsCard}>
{BMI_CATEGORIES.map((category, index) => {
const colors = [
@@ -320,7 +317,7 @@ const styles = StyleSheet.create({
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontSize: 14,
fontWeight: '800',
color: '#192126',
},
@@ -380,7 +377,7 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
bmiValue: {
fontSize: 32,
fontSize: 20,
fontWeight: '800',
marginRight: 12,
},
@@ -390,16 +387,16 @@ const styles = StyleSheet.create({
borderRadius: 12,
},
categoryText: {
fontSize: 14,
fontSize: 12,
fontWeight: '700',
},
bmiDescription: {
fontSize: 14,
fontSize: 12,
fontWeight: '600',
marginBottom: 8,
},
encouragementText: {
fontSize: 13,
fontSize: 10,
color: '#6B7280',
fontWeight: '500',
lineHeight: 18,

View File

@@ -0,0 +1,182 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { CircularRing } from './CircularRing';
type FitnessRingsCardProps = {
style?: any;
// 活动卡路里数据
activeCalories?: number;
activeCaloriesGoal?: number;
// 锻炼分钟数据
exerciseMinutes?: number;
exerciseMinutesGoal?: number;
// 站立小时数据
standHours?: number;
standHoursGoal?: number;
// 动画重置令牌
resetToken?: unknown;
};
/**
* 健身圆环卡片组件,模仿 Apple Watch 的健身圆环
*/
export function FitnessRingsCard({
style,
activeCalories = 25,
activeCaloriesGoal = 350,
exerciseMinutes = 1,
exerciseMinutesGoal = 5,
standHours = 2,
standHoursGoal = 13,
resetToken,
}: FitnessRingsCardProps) {
// 计算进度百分比
const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
return (
<View style={[styles.container, style]}>
<View style={styles.contentContainer}>
{/* 左侧圆环 */}
<View style={styles.ringsContainer}>
<View style={styles.ringWrapper}>
{/* 外圈 - 活动卡路里 (红色) */}
<View style={[styles.ringPosition]}>
<CircularRing
size={36}
strokeWidth={2.5}
trackColor="rgba(255, 59, 48, 0.15)"
progressColor="#FF3B30"
progress={caloriesProgress}
showCenterText={false}
resetToken={resetToken}
startAngleDeg={-90}
/>
</View>
{/* 中圈 - 锻炼分钟 (橙色) */}
<View style={[styles.ringPosition]}>
<CircularRing
size={26}
strokeWidth={2}
trackColor="rgba(255, 149, 0, 0.15)"
progressColor="#FF9500"
progress={exerciseProgress}
showCenterText={false}
resetToken={resetToken}
startAngleDeg={-90}
/>
</View>
{/* 内圈 - 站立小时 (蓝色) */}
<View style={[styles.ringPosition]}>
<CircularRing
size={16}
strokeWidth={1.5}
trackColor="rgba(0, 122, 255, 0.15)"
progressColor="#007AFF"
progress={standProgress}
showCenterText={false}
resetToken={resetToken}
startAngleDeg={-90}
/>
</View>
</View>
</View>
{/* 右侧数据显示 */}
<View style={styles.dataContainer}>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
<Text style={styles.dataValue}>{activeCalories}</Text>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
<Text style={styles.dataValue}>{exerciseMinutes}</Text>
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
<Text style={styles.dataValue}>{standHours}</Text>
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
</Text>
<Text style={styles.dataUnit}></Text>
</View>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
ringsContainer: {
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
ringWrapper: {
position: 'relative',
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
},
ringPosition: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
dataContainer: {
flex: 1,
gap: 3,
},
dataRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
dataText: {
fontSize: 12,
fontWeight: '700',
flex: 1,
},
dataValue: {
color: '#192126',
},
dataGoal: {
color: '#9AA3AE',
},
dataUnit: {
fontSize: 10,
color: '#9AA3AE',
fontWeight: '500',
minWidth: 25,
textAlign: 'right',
},
});

View File

@@ -96,7 +96,7 @@ export function NutritionRecordCard({
</View>
<View style={styles.timelineNode}>
<View style={[styles.timelineDot, { backgroundColor: mealTypeColor }]}>
<Ionicons name={mealTypeIcon as any} size={12} color="#FFFFFF" />
<Ionicons name={mealTypeIcon as any} size={10} color="#FFFFFF" />
</View>
{!isLast && (
<View style={[styles.timelineLine, { backgroundColor: textSecondaryColor }]} />
@@ -197,15 +197,15 @@ const styles = StyleSheet.create({
marginBottom: 12,
},
timelineColumn: {
width: 64,
width: 52,
alignItems: 'center',
paddingTop: 8,
paddingTop: 6,
},
timeContainer: {
marginBottom: 8,
marginBottom: 6,
},
timeText: {
fontSize: 12,
fontSize: 11,
fontWeight: '600',
textAlign: 'center',
},
@@ -214,22 +214,22 @@ const styles = StyleSheet.create({
flex: 1,
},
timelineDot: {
width: 24,
height: 24,
borderRadius: 12,
width: 20,
height: 20,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 3,
elevation: 1,
},
timelineLine: {
width: 2,
width: 1.5,
flex: 1,
marginTop: 8,
opacity: 0.3,
marginTop: 6,
opacity: 0.25,
},
card: {
flex: 1,
@@ -242,7 +242,7 @@ const styles = StyleSheet.create({
elevation: 2,
},
cardWithTimeline: {
marginLeft: 8,
marginLeft: 6,
},
mainContent: {
flexDirection: 'row',

View File

@@ -47,7 +47,7 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
// 计算进度条位置0-100%
// 压力指数越高,进度条越满
const progressPercentage = value === null ? 0 : value;
const progressPercentage = value !== null ? Math.max(0, Math.min(100, value)) : 0;
// 在组件内部添加状态
const [showStressModal, setShowStressModal] = useState(false);
@@ -68,7 +68,7 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
<View style={styles.header}>
<View style={styles.leftSection}>
<View style={styles.iconContainer}>
<Ionicons name="heart" size={16} color="#3B82F6" />
<Ionicons name="heart" size={16} color="red" />
</View>
<Text style={styles.title}></Text>
</View>
@@ -85,7 +85,7 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
<View style={styles.progressContainer}>
<View style={styles.progressTrack}>
{/* 渐变背景进度条 */}
<View style={[styles.progressBar, { width: `${progressPercentage}%` }]}>
<View style={[styles.progressBar, { width: '100%' }]}>
<LinearGradient
colors={['#10B981', '#FCD34D', '#F97316']}
start={{ x: 0, y: 0 }}
@@ -229,4 +229,4 @@ const styles = StyleSheet.create({
textAlign: 'right',
marginTop: 2,
},
});
});

View File

@@ -8,6 +8,7 @@ import dayjs from 'dayjs';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
Image,
StyleSheet,
Text,
TouchableOpacity,
@@ -170,7 +171,7 @@ export function WeightHistoryCard() {
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</View>
<Text style={styles.cardTitle}></Text>
</View>
@@ -187,7 +188,7 @@ export function WeightHistoryCard() {
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</View>
<Text style={styles.cardTitle}></Text>
</View>
@@ -220,7 +221,7 @@ export function WeightHistoryCard() {
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</View>
<Text style={styles.cardTitle}></Text>
</View>
@@ -271,7 +272,7 @@ export function WeightHistoryCard() {
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</View>
<Text style={styles.cardTitle}></Text>
<View style={styles.headerButtons}>
@@ -444,10 +445,10 @@ const styles = StyleSheet.create({
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
marginRight: 2,
},
cardTitle: {
fontSize: 18,
fontSize: 14,
fontWeight: '800',
color: '#192126',
flex: 1,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import { MaterialIcons } from '@expo/vector-icons';
import React from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
interface CustomCheckBoxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
size?: number;
checkedColor?: string;
uncheckedColor?: string;
}
const CustomCheckBox = (props: CustomCheckBoxProps) => {
const {
checked,
onCheckedChange,
size = 16,
checkedColor = '#E91E63',
uncheckedColor = '#999'
} = props;
return (
<TouchableOpacity
style={[styles.container, { width: size + 4, height: size + 4 }]}
onPress={() => onCheckedChange(!checked)}
activeOpacity={0.7}
>
{checked ? (
<MaterialIcons
name="check-box"
size={size}
color={checkedColor}
/>
) : (
<MaterialIcons
name="check-box-outline-blank"
size={size}
color={uncheckedColor}
/>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
});
export default CustomCheckBox;

View File

@@ -0,0 +1,114 @@
import { getDeviceDimensions } from '@/utils/native.utils';
import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { ratio } = getDeviceDimensions();
interface SuccessToastProps {
visible: boolean;
message: string;
duration?: number;
backgroundColor?: string;
textColor?: string;
icon?: string;
onHide?: () => void;
}
export default function SuccessToast({
visible,
message,
duration = 2000,
backgroundColor = '#DF42D0', // 默认使用应用主题色
textColor = '#FFFFFF',
icon = '✓',
onHide,
}: SuccessToastProps) {
const animValue = useRef(new Animated.Value(0)).current;
const insets = useSafeAreaInsets();
useEffect(() => {
if (visible) {
// 入场动画
Animated.sequence([
Animated.timing(animValue, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
// 停留时间
Animated.delay(duration - 600), // 减去入场和退场动画时间
// 退场动画
Animated.timing(animValue, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start(() => {
onHide?.();
});
}
}, [visible, duration, animValue, onHide]);
if (!visible) return null;
const translateY = animValue.interpolate({
inputRange: [0, 1],
outputRange: [-(insets.top + 60), 0], // 从安全区域上方滑入
});
const opacity = animValue;
return (
<Animated.View
style={[
styles.container,
{
top: insets.top + 10 * ratio, // 动态计算顶部安全距离
transform: [{ translateY }],
opacity,
}
]}
>
<View style={[styles.content, { backgroundColor }]}>
<Text style={[styles.icon, { color: textColor }]}>{icon}</Text>
<Text style={[styles.text, { color: textColor }]}>{message}</Text>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
// top 将由组件内部动态计算
left: 15 * ratio,
right: 15 * ratio,
zIndex: 1000,
alignItems: 'center',
},
content: {
paddingVertical: 12 * ratio,
paddingHorizontal: 20 * ratio,
borderRadius: 25 * ratio,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 10,
},
icon: {
fontSize: 16 * ratio,
fontWeight: 'bold',
marginRight: 8 * ratio,
},
text: {
fontSize: 14 * ratio,
fontWeight: '600',
},
});