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:
@@ -1,5 +1,5 @@
|
||||
import dayjs from 'dayjs';
|
||||
import type { HealthKitPermissions } from 'react-native-health';
|
||||
import type { HealthActivitySummary, HealthKitPermissions } from 'react-native-health';
|
||||
import AppleHealthKit from 'react-native-health';
|
||||
|
||||
const PERMISSIONS: HealthKitPermissions = {
|
||||
@@ -9,6 +9,7 @@ const PERMISSIONS: HealthKitPermissions = {
|
||||
AppleHealthKit.Constants.Permissions.ActiveEnergyBurned,
|
||||
AppleHealthKit.Constants.Permissions.SleepAnalysis,
|
||||
AppleHealthKit.Constants.Permissions.HeartRateVariability,
|
||||
AppleHealthKit.Constants.Permissions.ActivitySummary,
|
||||
],
|
||||
write: [
|
||||
// 支持体重写入
|
||||
@@ -22,6 +23,13 @@ export type TodayHealthData = {
|
||||
activeEnergyBurned: number; // kilocalories
|
||||
sleepDuration: number; // 睡眠时长(分钟)
|
||||
hrv: number | null; // 心率变异性 (ms)
|
||||
// 健身圆环数据
|
||||
activeCalories: number;
|
||||
activeCaloriesGoal: number;
|
||||
exerciseMinutes: number;
|
||||
exerciseMinutesGoal: number;
|
||||
standHours: number;
|
||||
standHoursGoal: number;
|
||||
};
|
||||
|
||||
export async function ensureHealthPermissions(): Promise<boolean> {
|
||||
@@ -57,13 +65,20 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
endDate: end.toISOString()
|
||||
} as any;
|
||||
|
||||
const activitySummaryOptions = {
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString()
|
||||
};
|
||||
|
||||
console.log('查询选项:', options);
|
||||
|
||||
// 并行获取所有健康数据
|
||||
const [steps, calories, sleepDuration, hrv] = await Promise.all([
|
||||
// 并行获取所有健康数据,包括ActivitySummary
|
||||
const [steps, calories, sleepDuration, hrv, activitySummary] = await Promise.all([
|
||||
// 获取步数
|
||||
new Promise<number>((resolve) => {
|
||||
AppleHealthKit.getStepCount(options, (err, res) => {
|
||||
AppleHealthKit.getStepCount({
|
||||
date: dayjs(date).toISOString()
|
||||
}, (err, res) => {
|
||||
if (err) {
|
||||
console.error('获取步数失败:', err);
|
||||
return resolve(0);
|
||||
@@ -144,11 +159,43 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取ActivitySummary数据(健身圆环数据)
|
||||
new Promise<HealthActivitySummary | null>((resolve) => {
|
||||
AppleHealthKit.getActivitySummary(
|
||||
activitySummaryOptions,
|
||||
(err: Object, results: HealthActivitySummary[]) => {
|
||||
if (err) {
|
||||
console.error('获取ActivitySummary失败:', err);
|
||||
return resolve(null);
|
||||
}
|
||||
if (!results || results.length === 0) {
|
||||
console.warn('ActivitySummary数据为空');
|
||||
return resolve(null);
|
||||
}
|
||||
console.log('ActivitySummary数据:', results[0]);
|
||||
resolve(results[0]);
|
||||
},
|
||||
);
|
||||
})
|
||||
]);
|
||||
|
||||
console.log('指定日期健康数据获取完成:', { steps, calories, sleepDuration, hrv });
|
||||
return { steps, activeEnergyBurned: calories, sleepDuration, hrv };
|
||||
console.log('指定日期健康数据获取完成:', { steps, calories, sleepDuration, hrv, activitySummary });
|
||||
|
||||
return {
|
||||
steps,
|
||||
activeEnergyBurned: calories,
|
||||
sleepDuration,
|
||||
hrv,
|
||||
// 健身圆环数据
|
||||
activeCalories: activitySummary?.activeEnergyBurned || 0,
|
||||
activeCaloriesGoal: activitySummary?.activeEnergyBurnedGoal || 350,
|
||||
exerciseMinutes: activitySummary?.appleExerciseTime || 0,
|
||||
exerciseMinutesGoal: activitySummary?.appleExerciseTimeGoal || 30,
|
||||
standHours: activitySummary?.appleStandHours || 0,
|
||||
standHoursGoal: activitySummary?.appleStandHoursGoal || 12
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchTodayHealthData(): Promise<TodayHealthData> {
|
||||
|
||||
24
utils/native.utils.ts
Normal file
24
utils/native.utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Dimensions, StatusBar } from 'react-native';
|
||||
import DeviceInfo from 'react-native-device-info';
|
||||
|
||||
export const getStatusBarHeight = () => {
|
||||
return StatusBar.currentHeight;
|
||||
};
|
||||
|
||||
export const getDeviceDimensions = () => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
// 检测是否为平板设备
|
||||
const isTablet = DeviceInfo.isTablet();
|
||||
|
||||
// 对于平板设备,使用不同的基准尺寸
|
||||
const baseWidth = isTablet ? 768 : 375; // iPad 通常使用 768pt 作为宽度基准
|
||||
const ratio = width / baseWidth;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
ratio,
|
||||
isTablet,
|
||||
};
|
||||
};
|
||||
113
utils/sentry.utils.ts
Normal file
113
utils/sentry.utils.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
|
||||
export const captureException = (error: Error) => {
|
||||
Sentry.captureException(error);
|
||||
};
|
||||
|
||||
export const captureMessage = (message: string) => {
|
||||
Sentry.captureMessage(message);
|
||||
};
|
||||
|
||||
// 智能处理大型对象的日志记录,避免截断
|
||||
export const captureMessageWithContext = (
|
||||
message: string,
|
||||
context?: Record<string, any>,
|
||||
level: 'debug' | 'info' | 'warning' | 'error' = 'info'
|
||||
) => {
|
||||
try {
|
||||
// 如果有上下文数据,优先使用 addBreadcrumb 或 setContext
|
||||
if (context) {
|
||||
// 检查 JSON 字符串长度
|
||||
const contextString = JSON.stringify(context);
|
||||
|
||||
if (contextString.length > 7000) { // 留 1000+ 字符给消息本身
|
||||
// 如果上下文数据太大,将其作为 context 附加到事件
|
||||
Sentry.withScope(scope => {
|
||||
scope.setContext('large_data', context);
|
||||
Sentry.captureMessage(`${message} (大型上下文数据已附加到事件上下文)`, level);
|
||||
});
|
||||
} else {
|
||||
// 数据较小时,直接包含在消息中
|
||||
Sentry.captureMessage(`${message}: ${contextString}`, level);
|
||||
}
|
||||
} else {
|
||||
Sentry.captureMessage(message, level);
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果 JSON.stringify 失败,回退到基本消息
|
||||
console.error('序列化上下文数据失败:', error);
|
||||
Sentry.captureMessage(`${message} (上下文数据序列化失败)`, level);
|
||||
}
|
||||
};
|
||||
|
||||
// 专门用于记录购买相关的日志,带有结构化数据
|
||||
export const capturePurchaseEvent = (
|
||||
eventType: 'init' | 'success' | 'error' | 'restore',
|
||||
message: string,
|
||||
data?: any
|
||||
) => {
|
||||
const tags = {
|
||||
event_type: 'purchase',
|
||||
purchase_action: eventType,
|
||||
};
|
||||
|
||||
Sentry.withScope(scope => {
|
||||
// 设置标签
|
||||
Object.entries(tags).forEach(([key, value]) => {
|
||||
scope.setTag(key, value);
|
||||
});
|
||||
|
||||
// 如果有数据,设置为上下文
|
||||
if (data) {
|
||||
scope.setContext('purchase_data', data);
|
||||
}
|
||||
|
||||
// 根据事件类型设置适当的级别
|
||||
const level = eventType === 'error' ? 'error' : 'info';
|
||||
Sentry.captureMessage(message, level);
|
||||
});
|
||||
};
|
||||
|
||||
// 记录用户操作日志
|
||||
export const captureUserAction = (
|
||||
action: string,
|
||||
details?: Record<string, any>
|
||||
) => {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `用户操作: ${action}`,
|
||||
category: 'user_action',
|
||||
data: details,
|
||||
level: 'info',
|
||||
});
|
||||
};
|
||||
|
||||
// 记录 API 调用日志
|
||||
export const captureApiCall = (
|
||||
endpoint: string,
|
||||
method: string,
|
||||
success: boolean,
|
||||
responseData?: any,
|
||||
error?: Error
|
||||
) => {
|
||||
const breadcrumb = {
|
||||
message: `API ${method} ${endpoint}`,
|
||||
category: 'http',
|
||||
data: {
|
||||
method,
|
||||
endpoint,
|
||||
success,
|
||||
} as any,
|
||||
level: success ? 'info' : 'error' as any,
|
||||
};
|
||||
|
||||
if (success && responseData) {
|
||||
// 对于成功的响应,只记录关键信息避免数据过大
|
||||
breadcrumb.data.response_keys = Object.keys(responseData);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
breadcrumb.data.error = error.message;
|
||||
}
|
||||
|
||||
Sentry.addBreadcrumb(breadcrumb);
|
||||
};
|
||||
58
utils/toast.utils.ts
Normal file
58
utils/toast.utils.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 全局Toast工具函数
|
||||
*
|
||||
* 使用方式:
|
||||
* import { Toast } from '@/utils/toast.utils';
|
||||
*
|
||||
* Toast.success('操作成功!');
|
||||
* Toast.error('操作失败!');
|
||||
* Toast.warning('注意!');
|
||||
*/
|
||||
|
||||
import { ToastContextType } from '@/contexts/ToastContext';
|
||||
|
||||
let toastRef: ToastContextType | null = null;
|
||||
|
||||
export const setToastRef = (ref: ToastContextType) => {
|
||||
toastRef = ref;
|
||||
};
|
||||
|
||||
export const Toast = {
|
||||
success: (message: string, duration?: number) => {
|
||||
if (toastRef) {
|
||||
toastRef.showSuccess(message, duration);
|
||||
} else {
|
||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
||||
}
|
||||
},
|
||||
|
||||
error: (message: string, duration?: number) => {
|
||||
if (toastRef) {
|
||||
toastRef.showError(message, duration);
|
||||
} else {
|
||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
||||
}
|
||||
},
|
||||
|
||||
warning: (message: string, duration?: number) => {
|
||||
if (toastRef) {
|
||||
toastRef.showWarning(message, duration);
|
||||
} else {
|
||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
||||
}
|
||||
},
|
||||
|
||||
show: (config: {
|
||||
message: string;
|
||||
duration?: number;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
icon?: string;
|
||||
}) => {
|
||||
if (toastRef) {
|
||||
toastRef.showToast(config);
|
||||
} else {
|
||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user