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

@@ -1,6 +1,6 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
import { BMICard } from '@/components/BMICard';
import { CircularRing } from '@/components/CircularRing';
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import { ProgressBar } from '@/components/ProgressBar';
import { StressMeter } from '@/components/StressMeter';
@@ -52,13 +52,21 @@ export default function ExploreScreen() {
// 日期条自动滚动到选中项
const daysScrollRef = useRef<import('react-native').ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 68;
const DAY_PILL_SPACING = 12;
const DAY_PILL_WIDTH = 48;
const DAY_PILL_SPACING = 8;
const scrollToIndex = (index: number, animated = true) => {
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
if (!daysScrollRef.current || scrollWidth === 0) return;
const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING;
const baseOffset = index * itemWidth;
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
// 确保不会滚动超出边界
const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth);
const finalOffset = Math.min(centerOffset, maxScrollOffset);
daysScrollRef.current.scrollTo({ x: finalOffset, animated });
};
useEffect(() => {
@@ -68,6 +76,14 @@ export default function ExploreScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// 当选中索引变化时,滚动到对应位置
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// HealthKit: 每次页面聚焦都拉取今日数据
const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null);
@@ -76,6 +92,15 @@ export default function ExploreScreen() {
// HRV数据
const [hrvValue, setHrvValue] = useState<number>(0);
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
// 健身圆环数据
const [fitnessRingsData, setFitnessRingsData] = useState({
activeCalories: 0,
activeCaloriesGoal: 350,
exerciseMinutes: 0,
exerciseMinutesGoal: 30,
standHours: 0,
standHoursGoal: 12
});
const [isLoading, setIsLoading] = useState(false);
// 用于触发动画重置的 token当日期或数据变化时更新
@@ -124,6 +149,15 @@ export default function ExploreScreen() {
setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned));
setSleepDuration(data.sleepDuration);
// 更新健身圆环数据
setFitnessRingsData({
activeCalories: data.activeCalories,
activeCaloriesGoal: data.activeCaloriesGoal,
exerciseMinutes: data.exerciseMinutes,
exerciseMinutesGoal: data.exerciseMinutesGoal,
standHours: data.standHours,
standHoursGoal: data.standHoursGoal
});
const hrv = data.hrv ?? 0;
setHrvValue(hrv);
@@ -195,7 +229,6 @@ export default function ExploreScreen() {
// 日期点击时,加载对应日期数据
const onSelectDate = (index: number) => {
setSelectedIndex(index);
scrollToIndex(index);
const target = days[index]?.date?.toDate();
if (target) {
loadHealthData(target);
@@ -320,19 +353,17 @@ export default function ExploreScreen() {
// compact={true}
/>
<View style={[styles.masonryCard, styles.trainingCard]}>
<Text style={styles.cardTitleSecondary}></Text>
<View style={styles.trainingContent}>
<CircularRing
size={120}
strokeWidth={12}
trackColor="#E2D9FD"
progressColor="#8B74F3"
progress={trainingProgress}
resetToken={animToken}
/>
</View>
</View>
<FitnessRingsCard
activeCalories={fitnessRingsData.activeCalories}
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
exerciseMinutes={fitnessRingsData.exerciseMinutes}
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
standHours={fitnessRingsData.standHours}
standHoursGoal={fitnessRingsData.standHoursGoal}
resetToken={animToken}
style={styles.masonryCard}
/>
<View style={[styles.masonryCard, styles.sleepCard]}>
<View style={styles.cardHeaderRow}>
@@ -396,13 +427,13 @@ const styles = StyleSheet.create({
},
dayItemWrapper: {
alignItems: 'center',
width: 68,
marginRight: 12,
width: 48,
marginRight: 8,
},
dayPill: {
width: 68,
height: 68,
borderRadius: 18,
width: 48,
height: 48,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
@@ -413,16 +444,16 @@ const styles = StyleSheet.create({
backgroundColor: lightColors.datePickerSelected,
},
dayLabel: {
fontSize: 16,
fontSize: 12,
fontWeight: '700',
color: '#192126',
marginBottom: 2,
marginBottom: 1,
},
dayLabelSelected: {
color: '#FFFFFF',
},
dayDate: {
fontSize: 16,
fontSize: 12,
fontWeight: '800',
color: '#192126',
},
@@ -430,12 +461,12 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
},
selectedDot: {
width: 8,
height: 8,
borderRadius: 4,
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: lightColors.datePickerSelected,
marginTop: 10,
marginBottom: 4,
marginTop: 6,
marginBottom: 2,
alignSelf: 'center',
},
sectionTitle: {
@@ -481,13 +512,13 @@ const styles = StyleSheet.create({
cardTitleSecondary: {
color: '#9AA3AE',
fontSize: 14,
fontSize: 10,
fontWeight: '600',
marginBottom: 10,
},
caloriesValue: {
color: '#192126',
fontSize: 22,
fontSize: 18,
fontWeight: '800',
},
trainingContent: {
@@ -569,8 +600,8 @@ const styles = StyleSheet.create({
marginBottom: 12,
},
iconSquare: {
width: 30,
height: 30,
width: 24,
height: 24,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
@@ -578,7 +609,7 @@ const styles = StyleSheet.create({
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontSize: 14,
fontWeight: '800',
color: '#192126',
},
@@ -606,7 +637,7 @@ const styles = StyleSheet.create({
backgroundColor: '#FFE4B8',
},
stepsValue: {
fontSize: 16,
fontSize: 14,
color: '#7A6A42',
fontWeight: '700',
marginBottom: 8,

View File

@@ -12,9 +12,9 @@ import { store } from '@/store';
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
import React from 'react';
import RNExitApp from 'react-native-exit-app';
import Toast from 'react-native-toast-message';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { ToastProvider } from '@/contexts/ToastContext';
import { Provider } from 'react-redux';
function Bootstrapper({ children }: { children: React.ReactNode }) {
@@ -58,7 +58,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
onAgree={handlePrivacyAgree}
onDisagree={handlePrivacyDisagree}
/>
<Toast />
</DialogProvider>
);
}
@@ -77,27 +76,28 @@ export default function RootLayout() {
return (
<Provider store={store}>
<Bootstrapper>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="challenge" options={{ headerShown: false }} />
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
<Stack.Screen name="workout" options={{ headerShown: false }} />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<ToastProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="challenge" options={{ headerShown: false }} />
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
<Stack.Screen name="workout" options={{ headerShown: false }} />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="nutrition/records" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
<Toast />
</ThemeProvider>
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="nutrition/records" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
</ToastProvider>
</Bootstrapper>
</Provider>
);

View File

@@ -43,21 +43,37 @@ export default function NutritionRecordsScreen() {
// 日期滚动相关
const daysScrollRef = useRef<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 68;
const DAY_PILL_SPACING = 12;
const DAY_PILL_WIDTH = 60; // 48px width + 12px marginRight = 60px total per item
const DAY_PILL_SPACING = 0; // spacing is included in the width above
// 日期滚动控制
const scrollToIndex = (index: number, animated = true) => {
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
if (scrollWidth <= 0) return;
const itemOffset = index * DAY_PILL_WIDTH;
const scrollViewCenterX = scrollWidth / 2;
const itemCenterX = DAY_PILL_WIDTH / 2;
const centerOffset = Math.max(0, itemOffset - scrollViewCenterX + itemCenterX);
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
};
// 初始化时滚动到选中位置
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
// 延迟滚动以确保ScrollView已经完全渲染
setTimeout(() => {
scrollToIndex(selectedIndex, false);
}, 100);
}
}, [scrollWidth, selectedIndex]);
}, [scrollWidth]);
// 选中日期变化时滚动
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
}, [selectedIndex]);
// 加载记录数据
const loadRecords = async (isRefresh = false, loadMore = false) => {
@@ -194,7 +210,6 @@ export default function NutritionRecordsScreen() {
onPress={() => {
if (!isDisabled) {
setSelectedIndex(index);
scrollToIndex(index);
}
}}
disabled={isDisabled}