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:
@@ -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,
|
||||
|
||||
182
components/FitnessRingsCard.tsx
Normal file
182
components/FitnessRingsCard.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
1176
components/model/MembershipModal.tsx
Normal file
1176
components/model/MembershipModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
53
components/ui/CheckBox.tsx
Normal file
53
components/ui/CheckBox.tsx
Normal 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;
|
||||
114
components/ui/SuccessToast.tsx
Normal file
114
components/ui/SuccessToast.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user