diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index f30e5dd..6199252 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -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(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(null); const [activeCalories, setActiveCalories] = useState(null); @@ -76,6 +92,15 @@ export default function ExploreScreen() { // HRV数据 const [hrvValue, setHrvValue] = useState(0); const [hrvUpdateTime, setHrvUpdateTime] = useState(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} /> - - 训练时间 - - - - + + @@ -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, diff --git a/app/_layout.tsx b/app/_layout.tsx index 3a99913..be2d090 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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} /> - ); } @@ -77,27 +76,28 @@ export default function RootLayout() { return ( - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + ); diff --git a/app/nutrition/records.tsx b/app/nutrition/records.tsx index 6579f44..34a442b 100644 --- a/app/nutrition/records.tsx +++ b/app/nutrition/records.tsx @@ -43,21 +43,37 @@ export default function NutritionRecordsScreen() { // 日期滚动相关 const daysScrollRef = useRef(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} diff --git a/assets/images/icons/iconWeight.png b/assets/images/icons/iconWeight.png new file mode 100644 index 0000000..9acc229 Binary files /dev/null and b/assets/images/icons/iconWeight.png differ diff --git a/components/BMICard.tsx b/components/BMICard.tsx index 8c60cd7..313f228 100644 --- a/components/BMICard.tsx +++ b/components/BMICard.tsx @@ -128,9 +128,6 @@ export function BMICard({ weight, height, style, compact = false }: BMICardProps > - - - BMI {!compact && ( @@ -219,7 +216,7 @@ export function BMICard({ weight, height, style, compact = false }: BMICardProps {/* BMI 分类标准 */} BMI 分类标准 - + {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, diff --git a/components/FitnessRingsCard.tsx b/components/FitnessRingsCard.tsx new file mode 100644 index 0000000..ab8979a --- /dev/null +++ b/components/FitnessRingsCard.tsx @@ -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 ( + + + {/* 左侧圆环 */} + + + {/* 外圈 - 活动卡路里 (红色) */} + + + + + {/* 中圈 - 锻炼分钟 (橙色) */} + + + + + {/* 内圈 - 站立小时 (蓝色) */} + + + + + + + {/* 右侧数据显示 */} + + + + {activeCalories} + /{activeCaloriesGoal} + + 千卡 + + + + + {exerciseMinutes} + /{exerciseMinutesGoal} + + 分钟 + + + + + {standHours} + /{standHoursGoal} + + 小时 + + + + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/components/NutritionRecordCard.tsx b/components/NutritionRecordCard.tsx index 800f20e..c03d67e 100644 --- a/components/NutritionRecordCard.tsx +++ b/components/NutritionRecordCard.tsx @@ -96,7 +96,7 @@ export function NutritionRecordCard({ - + {!isLast && ( @@ -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', diff --git a/components/StressMeter.tsx b/components/StressMeter.tsx index f3b83f2..0610e3c 100644 --- a/components/StressMeter.tsx +++ b/components/StressMeter.tsx @@ -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 - + 压力 @@ -85,7 +85,7 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP {/* 渐变背景进度条 */} - + - + 体重记录 @@ -187,7 +188,7 @@ export function WeightHistoryCard() { - + 体重记录 @@ -220,7 +221,7 @@ export function WeightHistoryCard() { - + 体重记录 @@ -271,7 +272,7 @@ export function WeightHistoryCard() { - + 体重记录 @@ -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, diff --git a/components/model/MembershipModal.tsx b/components/model/MembershipModal.tsx new file mode 100644 index 0000000..69b5f7d --- /dev/null +++ b/components/model/MembershipModal.tsx @@ -0,0 +1,1176 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import CustomCheckBox from '@/components/ui/CheckBox'; +import { USER_AGREEMENT_URL } from '@/constants/Agree'; +// import { useAuth } from '@/contexts/AuthContext'; +// import { UserApi } from '@/services'; +import { + captureMessage, + captureMessageWithContext, + capturePurchaseEvent, + captureUserAction +} from '@/utils/sentry.utils'; +import { Toast as GlobalToast } from '@/utils/toast.utils'; +import { MaterialIcons } from '@expo/vector-icons'; +import { captureException } from '@sentry/react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Dimensions, + Image, + Linking, + Modal, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; +import Purchases, { CustomerInfo, PurchasesStoreProduct } from 'react-native-purchases'; + +const { height } = Dimensions.get('window'); + +interface MembershipModalProps { + visible: boolean; + onClose?: () => void; + onPurchaseSuccess?: () => void; +} + +interface MembershipPlan { + id: string; + title: string; + subtitle: string; + price: string; + originalPrice?: string; + discount?: string; + type: 'weekly' | 'quarterly' | 'lifetime'; + recommended?: boolean; +} + +const DEFAULT_PLANS: MembershipPlan[] = [ + { + id: 'com.ilookai.mind_gpt.Lifetime', + title: '终身会员', + subtitle: '每天0.01元\n永久有效', + price: '¥128', + type: 'lifetime', + recommended: true, + }, + { + id: 'com.ilookai.mind_gpt.ThreeMonths', + title: '季度会员', + subtitle: '每天1元\n连续包季', + price: '¥98', + type: 'quarterly', + }, + { + id: 'weekly_membership', + title: '周会员', + subtitle: '新人专享\n连续包周', + price: '¥18', + type: 'weekly', + }, +]; + +// { +// identifier: 'com.ilookai.mind_gpt.Lifetime', +// description: '终身会员', +// title: '终身会员', +// price: 128, +// priceString: '¥128', +// pricePerWeek: 128, +// pricePerMonth: 128, +// pricePerYear: 128, + +// }, { +// identifier: 'com.ilookai.mind_gpt.ThreeMonths', +// description: '季度会员', +// title: '季度会员', +// price: 98, +// priceString: '¥98', +// pricePerWeek: 98, +// pricePerMonth: 98, +// pricePerYear: 98, +// }, { +// identifier: 'weekly_membership', +// description: '周会员', +// title: '周会员', +// price: 18, +// priceString: '¥18', +// pricePerWeek: 18, +// pricePerMonth: 18, +// pricePerYear: 18, +// } + +export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) { + const [selectedProduct, setSelectedProduct] = useState(null); + const [loading, setLoading] = useState(false); + const [restoring, setRestoring] = useState(false); + // const { user } = useAuth() + // const { refreshUserInfo } = useAuth(); + const [products, setProducts] = useState([]); + + // 协议同意状态 - 只需要一个状态 + const [agreementAccepted, setAgreementAccepted] = useState(false); + + // 保存监听器引用,用于移除监听器 + const purchaseListenerRef = useRef<((customerInfo: CustomerInfo) => void) | null>(null); + + // 根据选中的产品生成tips内容 + const getTipsContent = (product: PurchasesStoreProduct | null): string => { + if (!product) return ''; + + // 这里您可以根据不同的产品返回不同的提示内容 + switch (product.identifier) { + case 'com.ilookai.mind_gpt.Lifetime': + return '一次购买,永久享受所有功能'; + case 'com.ilookai.mind_gpt.ThreeMonths': + case 'weekly_membership': + return '到期后自动续费,可以随时随地取消'; + default: + return ''; + } + }; + + // useEffect(() => { + // if (user) { + // captureMessage('用户已登录,开始初始化 Purchases'); + // initPurchases(); + // } + // }, [user]); // 初始化只需要执行一次 + + // 单独的 useEffect 来处理购买监听器,依赖 visible 状态 + useEffect(() => { + if (visible) { + setupPurchaseListener(); + // 重置协议状态 + setAgreementAccepted(false); + } else { + // 弹窗关闭时移除监听器 + removePurchaseListener(); + } + + // 组件卸载时确保移除监听器 + return () => { + removePurchaseListener(); + console.log('MembershipModal 购买监听器已清理'); + }; + }, [visible]); // 依赖 visible 状态 + + + + const initPurchases = async () => { + if (Platform.OS === 'ios') { + Purchases.configure({ + apiKey: 'appl_UXFtPsBsFIsBOxoNGXoPwpXhGYk' + }); + } else if (Platform.OS === 'android') { + // Purchases.configure({ + // apiKey: 'goog_ZqYxWbQvRgLzBnVfYdXcWbVn' + // }); + } + + capturePurchaseEvent('init', 'Purchases 初始化成功'); + + try { + // if (user?.id) { + // await Purchases.logIn(user.id); + // captureMessageWithContext('用户登录 Purchases 成功', { + // userId: user.id + // }); + // } + + const offerings = await Purchases.getOfferings(); + console.log('offerings', offerings); + + captureMessageWithContext('获取产品套餐成功', { + currentOffering: offerings.current?.identifier || null, + availablePackagesCount: offerings.current?.availablePackages.length || 0, + allOfferingsCount: Object.keys(offerings.all).length + }); + + if (offerings.current !== null && offerings.current.availablePackages.length !== 0) { + // 当前活跃的套餐存在 + const packages = offerings.current.availablePackages; + console.log('Available packages:', packages); + // packages 是一个数组,包含 PurchasePackage 对象,每个对象都有 product 信息 + // 可以根据这些信息来渲染你的 UI,例如价格、标题等 + const sortedProducts = packages.sort((a, b) => b.product.price - a.product.price).map(pkg => pkg.product); + setProducts(sortedProducts); + + // 获取产品后,检查用户的购买记录并自动选中对应套餐 + await checkAndSelectActivePlan(sortedProducts); + } else { + console.warn('No active offerings found or no packages available.'); + captureMessageWithContext('没有找到可用的产品套餐', { + hasCurrentOffering: offerings.current !== null, + packagesLength: offerings.current?.availablePackages.length || 0 + }); + } + + } catch (e: any) { + console.log('error', e); + // Error fetching customer info + captureException(e); + // captureMessageWithContext('初始化 Purchases 失败', { + // error: e.message || '未知错误', + // userId: user?.id || null + // }); + } + } + + console.log('visible', visible); + + + // 添加购买状态监听器 + const setupPurchaseListener = () => { + console.log('设置购买监听器,当前 visible 状态:', visible); + + // 如果已经有监听器,先移除 + if (purchaseListenerRef.current) { + removePurchaseListener(); + } + + // 创建监听器函数 + const listener = (customerInfo: CustomerInfo) => { + console.log('addCustomerInfoUpdateListener:', customerInfo); + console.log('addCustomerInfoUpdateListener 触发时 visible 状态:', visible); + + // 检查是否有有效的购买记录 + const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0; + const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0; + const hasActiveSubscriptions = Object.keys(customerInfo.activeSubscriptions).length > 0; + + if (hasActiveEntitlements || hasNonSubscriptionTransactions || hasActiveSubscriptions) { + capturePurchaseEvent('success', '监听到购买状态变化', { + hasActiveEntitlements, + hasNonSubscriptionTransactions, + hasActiveSubscriptions, + activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, + nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, + activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length, + modalVisible: visible + }); + + console.log('检测到购买成功,刷新用户信息并关闭弹窗'); + + // 延迟一点时间,确保购买流程完全完成 + setTimeout(async () => { + // 刷新用户信息 + // await refreshUserInfo(); + + // 调用购买成功回调 + onPurchaseSuccess?.(); + + // 关闭弹窗 + onClose?.(); + + // 显示成功提示 + GlobalToast.show({ + message: '会员开通成功', + }); + }, 1000); + } + }; + + // 保存监听器引用 + purchaseListenerRef.current = listener; + + // 添加监听器 + Purchases.addCustomerInfoUpdateListener(listener); + + console.log('购买监听器已添加'); + }; + + // 移除购买状态监听器 + const removePurchaseListener = () => { + if (purchaseListenerRef.current) { + console.log('移除购买监听器'); + Purchases.removeCustomerInfoUpdateListener(purchaseListenerRef.current); + purchaseListenerRef.current = null; + console.log('购买监听器已移除'); + } + }; + + + // 检查用户的购买记录并自动选中对应套餐 + const checkAndSelectActivePlan = async (availableProducts: PurchasesStoreProduct[]) => { + try { + captureUserAction('开始检查用户购买记录'); + + // 获取用户的购买信息 + const customerInfo = await Purchases.getCustomerInfo(); + + console.log('用户购买信息:', customerInfo); + + // 记录详细的购买状态日志 + captureMessageWithContext('获取用户购买信息成功', { + activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, + nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, + activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length, + originalAppUserId: customerInfo.originalAppUserId, + firstSeen: customerInfo.firstSeen, + originalPurchaseDate: customerInfo.originalPurchaseDate, + latestExpirationDate: customerInfo.latestExpirationDate + }); + + // 查找激活的产品ID + let activePurchasedProductIds: string[] = []; + + // 检查权益 + Object.keys(customerInfo.entitlements.active).forEach(key => { + const entitlement = customerInfo.entitlements.active[key]; + activePurchasedProductIds.push(entitlement.productIdentifier); + console.log(`激活的权益: ${key}, 产品ID: ${entitlement.productIdentifier}`); + }); + + // 检查非订阅购买(如终身会员) + customerInfo.nonSubscriptionTransactions.forEach(transaction => { + activePurchasedProductIds.push(transaction.productIdentifier); + console.log(`非订阅购买: ${transaction.productIdentifier}, 购买时间: ${transaction.purchaseDate}`); + }); + + // 检查订阅 + Object.keys(customerInfo.activeSubscriptions).forEach(productId => { + activePurchasedProductIds.push(productId); + console.log(`激活的订阅: ${productId}`); + }); + + // 去重 + activePurchasedProductIds = [...new Set(activePurchasedProductIds)]; + + captureMessageWithContext('用户激活的产品列表', { + activePurchasedProductIds, + activePurchasedProductCount: activePurchasedProductIds.length + }); + + if (activePurchasedProductIds.length > 0) { + // 尝试在可用产品中找到匹配的产品 + let selectedProduct: PurchasesStoreProduct | null = null; + + // 优先级:终身会员 > 季度会员 > 周会员 + const priorityOrder = [ + 'com.ilookai.mind_gpt.Lifetime', + 'com.ilookai.mind_gpt.ThreeMonths', + 'weekly_membership' + ]; + + // 按照优先级查找 + for (const priorityProductId of priorityOrder) { + if (activePurchasedProductIds.includes(priorityProductId)) { + selectedProduct = availableProducts.find(product => product.identifier === priorityProductId) || null; + if (selectedProduct) { + console.log(`找到优先级最高的激活产品: ${priorityProductId}`); + break; + } + } + } + + // 如果按优先级没找到,尝试找到任何匹配的产品 + if (!selectedProduct) { + for (const productId of activePurchasedProductIds) { + selectedProduct = availableProducts.find(product => product.identifier === productId) || null; + if (selectedProduct) { + console.log(`找到匹配的激活产品: ${productId}`); + break; + } + } + } + + if (selectedProduct) { + setSelectedProduct(selectedProduct); + + captureMessageWithContext('自动选中用户已购买的套餐', { + selectedProductId: selectedProduct.identifier, + selectedProductTitle: selectedProduct.title, + selectedProductPrice: selectedProduct.price, + allActivePurchasedProductIds: activePurchasedProductIds + }); + + } else { + captureMessageWithContext('未找到匹配的可用产品', { + activePurchasedProductIds, + availableProductIds: availableProducts.map(p => p.identifier) + }); + console.log('用户有激活的购买记录,但没有找到匹配的可用产品'); + } + } else { + captureMessageWithContext('用户没有激活的购买记录', { + hasEntitlements: Object.keys(customerInfo.entitlements.active).length > 0, + hasNonSubscriptions: customerInfo.nonSubscriptionTransactions.length > 0, + hasActiveSubscriptions: Object.keys(customerInfo.activeSubscriptions).length > 0 + }); + console.log('用户没有激活的购买记录,使用默认选择逻辑'); + } + + } catch (error: any) { + console.log('检查用户购买记录失败:', error); + captureException(error); + captureMessageWithContext('检查用户购买记录失败', { + error: error.message || '未知错误', + errorCode: error.code || null + }); + } + }; + + + const handlePurchase = async () => { + // 验证是否已同意协议 + if (!agreementAccepted) { + Alert.alert( + '请阅读并同意相关协议', + '购买前需要同意用户协议、会员协议和自动续费协议', + [ + { + text: '确定', + style: 'default', + } + ] + ); + return; + } + + // 验证是否选择了产品 + if (!selectedProduct) { + Alert.alert( + '请选择会员套餐', + '', + [ + { + text: '确定', + style: 'default', + } + ] + ); + return; + } + + + + // 防止重复点击 + if (loading) { + return; + } + + try { + // 设置加载状态 + setLoading(true); + + // 记录购买开始事件 + capturePurchaseEvent('init', `开始购买: ${selectedProduct.identifier}`, { + productIdentifier: selectedProduct.identifier, + productTitle: selectedProduct.title, + productPrice: selectedProduct.price + }); + + // 执行购买 + const { customerInfo, productIdentifier } = await Purchases.purchaseStoreProduct(selectedProduct); + + console.log('购买成功 - customerInfo:', customerInfo); + console.log('购买成功 - productIdentifier:', productIdentifier); + + // 记录购买成功事件 + capturePurchaseEvent('success', `购买成功: ${productIdentifier}`, { + productIdentifier, + hasActiveEntitlements: Object.keys(customerInfo.entitlements.active).length > 0, + activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, + nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, + activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length + }); + + // 购买成功后,监听器会自动处理后续逻辑(刷新用户信息、关闭弹窗等) + console.log('购买流程完成,等待监听器处理后续逻辑'); + + } catch (error: any) { + captureException(error); + + // 记录购买失败事件 + capturePurchaseEvent('error', `购买失败: ${error.message || '未知错误'}`, { + errorCode: error.code || null, + errorMessage: error.message || '未知错误', + productIdentifier: selectedProduct.identifier + }); + + // 处理不同类型的购买错误 + if (error.code === 1 || error.code === 'USER_CANCELLED') { + // 用户取消购买 + GlobalToast.show({ + message: '购买已取消', + }); + } else if (error.code === 'ITEM_ALREADY_OWNED' || error.code === 'PRODUCT_ALREADY_PURCHASED') { + // 商品已拥有 + GlobalToast.show({ + message: '您已拥有此商品', + }); + } else if (error.code === 'NETWORK_ERROR') { + // 网络错误 + GlobalToast.show({ + message: '网络连接失败', + }); + } else if (error.code === 'PAYMENT_PENDING') { + // 支付待处理 + GlobalToast.show({ + message: '支付正在处理中', + }); + } else if (error.code === 'INVALID_CREDENTIALS') { + // 凭据无效 + GlobalToast.show({ + message: '账户验证失败', + }); + } else { + // 其他错误 + GlobalToast.show({ + message: '购买失败', + }); + } + } finally { + // 确保在所有情况下都重置加载状态 + setLoading(false); + console.log('购买流程结束,加载状态已重置'); + } + }; + + const handleRestore = async () => { + // 防止重复点击 + if (restoring || loading) { + return; + } + + try { + setRestoring(true); + + captureUserAction('开始恢复购买'); + + // 恢复购买 + const customerInfo = await Purchases.restorePurchases(); + + console.log('恢复购买结果:', customerInfo); + captureMessageWithContext('恢复购买结果', { + activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, + nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, + activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length, + managementUrl: customerInfo.managementURL, + originalAppUserId: customerInfo.originalAppUserId + }); + + // 检查是否有有效的购买记录 + const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0; + const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0; + const hasActiveSubscriptions = Object.keys(customerInfo.activeSubscriptions).length > 0; + + if (hasActiveEntitlements || hasNonSubscriptionTransactions || hasActiveSubscriptions) { + // 检查具体的购买内容 + let restoredProducts: string[] = []; + + // 检查权益 + Object.keys(customerInfo.entitlements.active).forEach(key => { + const entitlement = customerInfo.entitlements.active[key]; + restoredProducts.push(entitlement.productIdentifier); + }); + + // 检查非订阅购买(如终身会员) + customerInfo.nonSubscriptionTransactions.forEach(transaction => { + restoredProducts.push(transaction.productIdentifier); + }); + + // 检查订阅 + Object.keys(customerInfo.activeSubscriptions).forEach(productId => { + restoredProducts.push(productId); + }); + + console.log('恢复的产品:', restoredProducts); + capturePurchaseEvent('restore', '恢复购买成功', { + restoredProducts, + restoredProductsCount: restoredProducts.length + }); + + try { + // 调用后台服务接口进行票据匹配 + captureUserAction('开始调用后台恢复购买接口'); + + // const restoreResponse = await UserApi.restorePurchase({ + // customerInfo: { + // originalAppUserId: customerInfo.originalAppUserId, + // activeEntitlements: customerInfo.entitlements.active, + // nonSubscriptionTransactions: customerInfo.nonSubscriptionTransactions, + // activeSubscriptions: customerInfo.activeSubscriptions, + // restoredProducts + // } + // }); + + // console.log('后台恢复购买响应:', restoreResponse); + + // captureMessageWithContext('后台恢复购买成功', { + // responseData: restoreResponse, + // restoredProductsCount: restoredProducts.length + // }); + + // 刷新用户信息 + // await refreshUserInfo(); + + // 调用购买成功回调 + onPurchaseSuccess?.(); + + // 关闭弹窗 + onClose?.(); + + GlobalToast.show({ + message: '恢复购买成功', + }); + + } catch (apiError: any) { + console.log('后台恢复购买接口调用失败:', apiError); + + captureException(apiError); + captureMessageWithContext('后台恢复购买接口失败', { + error: apiError.message || '未知错误', + errorCode: apiError.code || null, + restoredProductsCount: restoredProducts.length + }); + + // 即使后台接口失败,也显示恢复成功(因为 RevenueCat 已经确认有购买记录) + // 但不关闭弹窗,让用户知道可能需要重试 + GlobalToast.show({ + message: '恢复购买部分失败', + }); + } + + } else { + capturePurchaseEvent('restore', '没有找到购买记录', { + hasActiveEntitlements, + hasNonSubscriptionTransactions, + hasActiveSubscriptions, + activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length, + nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length, + activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length + }); + GlobalToast.show({ + message: '没有找到购买记录', + }); + } + } catch (error: any) { + console.log('恢复购买失败:', error); + captureException(error); + + // 记录恢复购买失败事件 + capturePurchaseEvent('error', `恢复购买失败: ${error.message || '未知错误'}`, { + errorCode: error.code || null, + errorMessage: error.message || '未知错误' + }); + + // 处理特定的恢复购买错误 + if (error.code === 'RESTORE_CANCELLED' || error.code === 'USER_CANCELLED') { + GlobalToast.show({ + message: '恢复购买已取消', + }); + } else if (error.code === 'NETWORK_ERROR') { + GlobalToast.show({ + message: '网络错误', + }); + } else if (error.code === 'INVALID_CREDENTIALS') { + GlobalToast.show({ + message: '账户验证失败', + }); + } else { + GlobalToast.show({ + message: '恢复购买失败', + }); + } + } finally { + // 确保在所有情况下都重置恢复状态 + setRestoring(false); + console.log('恢复购买流程结束,恢复状态已重置'); + } + }; + + const renderMembershipBenefits = () => ( + + + + + 获得无限制的回复次数 + + + + + 获得无限制的个人专属话题库 + + + + + 获得无广告优质体验 + + + + + 获得未来免费升级,开启更多功能 + + + {/* 皇冠图标 */} + + + + + ); + + const renderPlanCard = (product: PurchasesStoreProduct) => { + const plan = DEFAULT_PLANS.find(p => p.id === product.identifier); + + if (!plan) { + return null; + } + + const isSelected = selectedProduct === product; + + return ( + !loading && product && setSelectedProduct(product)} + disabled={loading} + activeOpacity={loading ? 1 : 0.8} + accessible={true} + accessibilityLabel={`${product.title} ${plan.price}`} + accessibilityHint={loading ? "购买进行中,无法切换套餐" : `选择${product.title}套餐`} + accessibilityState={{ disabled: loading, selected: isSelected }} + > + {product.identifier === 'com.ilookai.mind_gpt.Lifetime' && ( + + 推荐 + + )} + + + + {product.title} + + + + + + {plan.price} + + + + {plan.subtitle} + + ); + }; + + + return ( + + + {/* 半透明背景 */} + + + {/* 会员内容 */} + + + {/* 关闭按钮 */} + {/* + × + */} + + + {/* 会员权益介绍 */} + {renderMembershipBenefits()} + + {/* 会员套餐选择 */} + + {products.map(renderPlanCard)} + + + {/* 产品选择提示区域 */} + {selectedProduct && ( + + + {getTipsContent(selectedProduct)} + + + )} + + {/* 协议同意区域 */} + + + 我已阅读并同意 + { + Linking.openURL(USER_AGREEMENT_URL); + captureMessage('click user agreement'); + }}> + 《用户协议》 + + | + { + // Linking.openURL(MEMBERSHIP_AGREEMENT_URL); + captureMessage('click membership agreement'); + }}> + 《会员协议》 + + | + { + // Linking.openURL(AUTO_RENEWAL_AGREEMENT_URL); + captureMessage('click auto renewal agreement'); + }}> + 《自动续费协议》 + + + + + {/* 购买按钮 */} + + {loading ? ( + + + 正在处理购买... + + ) : ( + 购买 + )} + + + {/* 恢复购买按钮 */} + + {restoring ? ( + + + 恢复中... + + ) : ( + 恢复购买 + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + }, + modalContainer: { + height: height * 0.6, + backgroundColor: 'white', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + overflow: 'hidden', + }, + modalContent: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 20, + }, + closeButton: { + position: 'absolute', + top: 0, + right: 0, + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(0, 0, 0, 0.1)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + closeButtonText: { + fontSize: 18, + color: '#666', + fontWeight: '300', + }, + configurationNotice: { + backgroundColor: '#FFF9E6', + borderRadius: 12, + padding: 16, + marginTop: 30, + marginBottom: 20, + alignItems: 'center', + borderWidth: 1, + borderColor: '#FFE4B5', + }, + configurationText: { + fontSize: 14, + color: '#B8860B', + textAlign: 'center', + marginTop: 8, + marginBottom: 12, + }, + configurationButton: { + backgroundColor: '#FF9500', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 8, + }, + configurationButtonText: { + color: 'white', + fontSize: 12, + fontWeight: '600', + }, + benefitsContainer: { + position: 'relative', + backgroundColor: '#FFE7F9', + borderRadius: 16, + marginBottom: 30, + padding: 20, + }, + benefitsTitleBg: { + width: 180, + height: 20, + marginBottom: 10, + }, + benefitItem: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + }, + benefitText: { + fontSize: 14, + color: '#333', + marginLeft: 8, + fontWeight: '500', + }, + crownContainer: { + position: 'absolute', + top: 10, + right: 10, + width: 100, + height: 100, + justifyContent: 'center', + alignItems: 'center', + }, + crownIcon: { + width: 80, + height: 80, + resizeMode: 'contain', + }, + plansContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 20, + }, + planCard: { + flex: 1, + marginHorizontal: 4, + borderRadius: 12, + borderWidth: 2, + borderColor: '#E0E0E0', + paddingVertical: 20, + paddingHorizontal: 12, + alignItems: 'center', + position: 'relative', + backgroundColor: 'white', + }, + selectedPlan: { + borderColor: '#DF42D0', + backgroundColor: '#FFF5FE', + }, + recommendedBadge: { + position: 'absolute', + top: -10, + backgroundColor: '#7B2CBF', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 4, + }, + recommendedText: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, + planHeader: { + alignItems: 'center', + marginBottom: 8, + }, + planTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + }, + lifetimePlanTitle: { + color: '#7B2CBF', + }, + quarterlyPlanTitle: { + color: '#DF42D0', + }, + weeklyPlanTitle: { + color: '#FF9500', + }, + planPricing: { + alignItems: 'center', + marginBottom: 8, + }, + planPrice: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + }, + lifetimePlanPrice: { + color: '#7B2CBF', + }, + quarterlyPlanPrice: { + color: '#DF42D0', + }, + weeklyPlanPrice: { + color: '#FF9500', + }, + planSubtitle: { + fontSize: 12, + color: '#666', + textAlign: 'center', + lineHeight: 16, + }, + purchaseButton: { + backgroundColor: '#DF42D0', + borderRadius: 25, + height: 50, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 15, + marginTop: 10, + shadowColor: '#DF42D0', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + disabledButton: { + backgroundColor: '#ccc', + shadowOpacity: 0, + elevation: 0, + }, + purchaseButtonText: { + color: 'white', + fontSize: 18, + fontWeight: '600', + }, + restoreButton: { + backgroundColor: 'transparent', + borderRadius: 25, + height: 50, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + }, + restoreButtonText: { + color: '#666', + fontSize: 16, + fontWeight: '500', + }, + disabledRestoreButton: { + opacity: 0.5, + }, + restoreButtonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + restoreButtonLoader: { + marginRight: 8, + }, + loadingContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + loadingSpinner: { + marginRight: 8, + }, + disabledPlanCard: { + opacity: 0.5, + }, + agreementRow: { + width: '100%', + marginTop: 6, + borderRadius: 12, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + agreementPrefix: { + fontSize: 10, + color: '#333', + marginRight: 4, + }, + agreementLink: { + fontSize: 10, + color: '#E91E63', + textDecorationLine: 'underline', + fontWeight: '500', + }, + agreementSeparator: { + fontSize: 10, + color: '#666', + marginHorizontal: 2, + }, + tipsContainer: { + borderRadius: 8, + }, + tipsText: { + fontSize: 12, + color: '#666', + lineHeight: 18, + textAlign: 'center', + }, +}); \ No newline at end of file diff --git a/components/ui/CheckBox.tsx b/components/ui/CheckBox.tsx new file mode 100644 index 0000000..cdce63b --- /dev/null +++ b/components/ui/CheckBox.tsx @@ -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 ( + onCheckedChange(!checked)} + activeOpacity={0.7} + > + {checked ? ( + + ) : ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + marginRight: 8, + }, +}); + +export default CustomCheckBox; \ No newline at end of file diff --git a/components/ui/SuccessToast.tsx b/components/ui/SuccessToast.tsx new file mode 100644 index 0000000..40545e5 --- /dev/null +++ b/components/ui/SuccessToast.tsx @@ -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 ( + + + {icon} + {message} + + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/contexts/ToastContext.tsx b/contexts/ToastContext.tsx new file mode 100644 index 0000000..84e59f6 --- /dev/null +++ b/contexts/ToastContext.tsx @@ -0,0 +1,107 @@ +import SuccessToast from '@/components/ui/SuccessToast'; +import { setToastRef } from '@/utils/toast.utils'; +import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; + +interface ToastConfig { + message: string; + duration?: number; + backgroundColor?: string; + textColor?: string; + icon?: string; +} + +export interface ToastContextType { + showToast: (config: ToastConfig) => void; + showSuccess: (message: string, duration?: number) => void; + showError: (message: string, duration?: number) => void; + showWarning: (message: string, duration?: number) => void; +} + +const ToastContext = createContext(undefined); + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [visible, setVisible] = useState(false); + const [config, setConfig] = useState({ message: '' }); + const timeoutRef = useRef(null); + + const showToast = (toastConfig: ToastConfig) => { + // 如果已有Toast显示,先隐藏 + if (visible) { + setVisible(false); + // 短暂延迟后显示新Toast + setTimeout(() => { + setConfig(toastConfig); + setVisible(true); + }, 100); + } else { + setConfig(toastConfig); + setVisible(true); + } + }; + + const showSuccess = (message: string, duration?: number) => { + showToast({ + message, + duration, + backgroundColor: '#DF42D0', // 主题色 + icon: '✓', + }); + }; + + const showError = (message: string, duration?: number) => { + showToast({ + message, + duration, + backgroundColor: '#f44336', // 红色 + icon: '✕', + }); + }; + + const showWarning = (message: string, duration?: number) => { + showToast({ + message, + duration, + backgroundColor: '#ff9800', // 橙色 + icon: '⚠', + }); + }; + + const handleHide = () => { + setVisible(false); + }; + + const value: ToastContextType = { + showToast, + showSuccess, + showError, + showWarning, + }; + + // 设置全局引用 + useEffect(() => { + setToastRef(value); + }, [value]); + + return ( + + {children} + + + ); +} + +export function useToast(): ToastContextType { + const context = useContext(ToastContext); + if (context === undefined) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 096cb1a..281c1e6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -113,6 +113,8 @@ PODS: - libwebp/sharpyuv (1.5.0) - libwebp/webp (1.5.0): - libwebp/sharpyuv + - PurchasesHybridCommon (16.2.2): + - RevenueCat (= 5.34.0) - QCloudCore (6.5.1): - QCloudCore/Default (= 6.5.1) - QCloudCore/Default (6.5.1): @@ -1701,6 +1703,7 @@ PODS: - React-logger (= 0.79.5) - React-perflogger (= 0.79.5) - React-utils (= 0.79.5) + - RevenueCat (5.34.0) - RNAppleHealthKit (1.7.0): - React - RNCAsyncStorage (2.2.0): @@ -1730,6 +1733,8 @@ PODS: - Yoga - RNDateTimePicker (8.4.4): - React-Core + - RNDeviceInfo (14.0.4): + - React-Core - RNExitApp (2.0.0): - React-Core - RNGestureHandler (2.24.0): @@ -1755,6 +1760,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNPurchases (9.2.2): + - PurchasesHybridCommon (= 16.2.2) + - React-Core - RNReanimated (3.17.5): - DoubleConversion - glog @@ -1898,6 +1906,30 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNSentry (6.20.0): + - DoubleConversion + - glog + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsc + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Sentry/HybridSDK (= 8.53.2) + - Yoga - RNSVG (15.12.1): - React-Core - SDWebImage (5.21.1): @@ -1911,6 +1943,7 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) + - Sentry/HybridSDK (8.53.2) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2012,10 +2045,13 @@ DEPENDENCIES: - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" + - RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNExitApp (from `../node_modules/react-native-exit-app`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) + - RNPurchases (from `../node_modules/react-native-purchases`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - "RNSentry (from `../node_modules/@sentry/react-native`)" - RNSVG (from `../node_modules/react-native-svg`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -2024,13 +2060,16 @@ SPEC REPOS: - libavif - libdav1d - libwebp + - PurchasesHybridCommon - QCloudCore - QCloudCOSXML - QCloudTrack + - RevenueCat - SDWebImage - SDWebImageAVIFCoder - SDWebImageSVGCoder - SDWebImageWebPCoder + - Sentry - SocketRocket EXTERNAL SOURCES: @@ -2224,14 +2263,20 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-masked-view/masked-view" RNDateTimePicker: :path: "../node_modules/@react-native-community/datetimepicker" + RNDeviceInfo: + :path: "../node_modules/react-native-device-info" RNExitApp: :path: "../node_modules/react-native-exit-app" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" + RNPurchases: + :path: "../node_modules/react-native-purchases" RNReanimated: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNSentry: + :path: "../node_modules/@sentry/react-native" RNSVG: :path: "../node_modules/react-native-svg" Yoga: @@ -2267,6 +2312,7 @@ SPEC CHECKSUMS: libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + PurchasesHybridCommon: 62f852419aae7041792217593998f7ac3f8b567d QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798 QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7 QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8 @@ -2335,19 +2381,24 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: f3e842e6cb5a825b6918a74a38402ba1409411f8 ReactCodegen: 272c9bc1a8a917bf557bd9d032a4b3e181c6abfe ReactCommon: 7eb76fcd5133313d8c6a138a5c7dd89f80f189d5 + RevenueCat: eb2aa042789d9c99ad5172bd96e28b96286d6ada RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9 RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96 RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c + RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047 RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4 RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389 + RNPurchases: 7993b33416e67d5863140b5c62c682b34719f475 RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8 + RNSentry: 7fbd30d392b5ac268cdebe085bfd7830c735a4d6 RNSVG: 3544def7b3ddc43c7ba69dade91bacf99f10ec46 SDWebImage: f29024626962457f3470184232766516dee8dfea SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 + Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: adb397651e1c00672c12e9495babca70777e411e diff --git a/ios/digitalpilates.xcodeproj/project.pbxproj b/ios/digitalpilates.xcodeproj/project.pbxproj index 11a5b95..6bffcd5 100644 --- a/ios/digitalpilates.xcodeproj/project.pbxproj +++ b/ios/digitalpilates.xcodeproj/project.pbxproj @@ -268,13 +268,17 @@ "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/QCloudCOSXML/QCloudCOSXML.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", ); @@ -284,13 +288,17 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/QCloudCOSXML.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", ); diff --git a/package-lock.json b/package-lock.json index 052dbe5..1a1446e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", "@reduxjs/toolkit": "^2.8.2", + "@sentry/react-native": "^6.20.0", "cos-js-sdk-v5": "^1.6.0", "dayjs": "^1.11.13", "expo": "~53.0.20", @@ -38,12 +39,14 @@ "react-dom": "19.0.0", "react-native": "0.79.5", "react-native-cos-sdk": "^1.2.1", + "react-native-device-info": "^14.0.4", "react-native-exit-app": "^2.0.0", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", "react-native-image-viewing": "^0.2.2", "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", + "react-native-purchases": "^9.2.2", "react-native-reanimated": "~3.17.4", "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", @@ -3307,6 +3310,27 @@ } } }, + "node_modules/@revenuecat/purchases-js": { + "version": "1.11.1", + "resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-js/-/purchases-js-1.11.1.tgz", + "integrity": "sha512-P0jxwUBWOIFSZQ1/NIMpbOXG3brraNDGYoCnES1r5w97yonhAw1brpKwhFKUhlq+DvAUDCG1q1d8FdTzI+MgXg==", + "license": "MIT" + }, + "node_modules/@revenuecat/purchases-js-hybrid-mappings": { + "version": "16.2.1", + "resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-16.2.1.tgz", + "integrity": "sha512-TXYw6lh5rg/kGI44kayU4TGSXKDcc35TdB0vBuZfllSokY1tnyYmP8Pm2eZamLN8ycrTuCysoPxknW2Klh1H1g==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-js": "1.11.1" + } + }, + "node_modules/@revenuecat/purchases-typescript-internal": { + "version": "16.2.1", + "resolved": "https://mirrors.tencent.com/npm/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-16.2.1.tgz", + "integrity": "sha512-g7FhNA6nxr9686klimlfueMQqQl34pHUHXeCKXqeuaPJOOsFc7qcOGhGZdyLGulIAgpkctrvcAbeDyBk7t5QRg==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3314,6 +3338,349 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry-internal/browser-utils": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz", + "integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.55.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/feedback/-/feedback-8.55.0.tgz", + "integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.55.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay/-/replay-8.55.0.tgz", + "integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.55.0", + "@sentry/core": "8.55.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz", + "integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "8.55.0", + "@sentry/core": "8.55.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "4.1.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.1.tgz", + "integrity": "sha512-HUpqrCK7zDVojTV6KL6BO9ZZiYrEYQqvYQrscyMsq04z+WCupXaH6YEliiNRvreR8DBJgdsG3lBRpebhUGmvfA==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry/browser/-/browser-8.55.0.tgz", + "integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.55.0", + "@sentry-internal/feedback": "8.55.0", + "@sentry-internal/replay": "8.55.0", + "@sentry-internal/replay-canvas": "8.55.0", + "@sentry/core": "8.55.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli/-/cli-2.51.1.tgz", + "integrity": "sha512-FU+54kNcKJABU0+ekvtnoXHM9zVrDe1zXVFbQT7mS0On0m1P0zFRGdzbnWe2XzpzuEAJXtK6aog/W+esRU9AIA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.51.1", + "@sentry/cli-linux-arm": "2.51.1", + "@sentry/cli-linux-arm64": "2.51.1", + "@sentry/cli-linux-i686": "2.51.1", + "@sentry/cli-linux-x64": "2.51.1", + "@sentry/cli-win32-arm64": "2.51.1", + "@sentry/cli-win32-i686": "2.51.1", + "@sentry/cli-win32-x64": "2.51.1" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-darwin/-/cli-darwin-2.51.1.tgz", + "integrity": "sha512-R1u8IQdn/7Rr8sf6bVVr0vJT4OqwCFdYsS44Y3OoWGVJW2aAQTWRJOTlV4ueclVLAyUQzmgBjfR8AtiUhd/M5w==", + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-linux-arm/-/cli-linux-arm-2.51.1.tgz", + "integrity": "sha512-Klro17OmSSKOOSaxVKBBNPXet2+HrIDZUTSp8NRl4LQsIubdc1S/aQ79cH/g52Muwzpl3aFwPxyXw+46isfEgA==", + "cpu": [ + "arm" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.51.1.tgz", + "integrity": "sha512-nvA/hdhsw4bKLhslgbBqqvETjXwN1FVmwHLOrRvRcejDO6zeIKUElDiL5UOjGG0NC+62AxyNw5ri8Wzp/7rg9Q==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-linux-i686/-/cli-linux-i686-2.51.1.tgz", + "integrity": "sha512-jp4TmR8VXBdT9dLo6mHniQHN0xKnmJoPGVz9h9VDvO2Vp/8o96rBc555D4Am5wJOXmfuPlyjGcmwHlB3+kQRWw==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-linux-x64/-/cli-linux-x64-2.51.1.tgz", + "integrity": "sha512-JuLt0MXM2KHNFmjqXjv23sly56mJmUQzGBWktkpY3r+jE08f5NLKPd5wQ6W/SoLXGIOKnwLz0WoUg7aBVyQdeQ==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.51.1.tgz", + "integrity": "sha512-PiwjTdIFDazTQCTyDCutiSkt4omggYSKnO3HE1+LDjElsFrWY9pJs4fU3D40WAyE2oKu0MarjNH/WxYGdqEAlg==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-win32-i686/-/cli-win32-i686-2.51.1.tgz", + "integrity": "sha512-TMvZZpeiI2HmrDFNVQ0uOiTuYKvjEGOZdmUxe3WlhZW82A/2Oka7sQ24ljcOovbmBOj5+fjCHRUMYvLMCWiysA==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.51.1", + "resolved": "https://mirrors.tencent.com/npm/@sentry/cli-win32-x64/-/cli-win32-x64-2.51.1.tgz", + "integrity": "sha512-v2hreYUPPTNK1/N7+DeX7XBN/zb7p539k+2Osf0HFyVBaoUC3Y3+KBwSf4ASsnmgTAK7HCGR+X0NH1vP+icw4w==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://mirrors.tencent.com/npm/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/core": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry/core/-/core-8.55.0.tgz", + "integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/react": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry/react/-/react-8.55.0.tgz", + "integrity": "sha512-/qNBvFLpvSa/Rmia0jpKfJdy16d4YZaAnH/TuKLAtm0BWlsPQzbXCU4h8C5Hsst0Do0zG613MEtEmWpWrVOqWA==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "8.55.0", + "@sentry/core": "8.55.0", + "hoist-non-react-statics": "^3.3.2" + }, + "engines": { + "node": ">=14.18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/react-native": { + "version": "6.20.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry/react-native/-/react-native-6.20.0.tgz", + "integrity": "sha512-YngSba14Hsb5t/ZNMOyxb/HInmYRL5pQ74BkoMBQ/UBBM5kWHgSILxoO2XkKYtaaJXrkSJj+kBalELHblz9h5g==", + "license": "MIT", + "dependencies": { + "@sentry/babel-plugin-component-annotate": "4.1.1", + "@sentry/browser": "8.55.0", + "@sentry/cli": "2.51.1", + "@sentry/core": "8.55.0", + "@sentry/react": "8.55.0", + "@sentry/types": "8.55.0", + "@sentry/utils": "8.55.0" + }, + "bin": { + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + }, + "peerDependencies": { + "expo": ">=49.0.0", + "react": ">=17.0.0", + "react-native": ">=0.65.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@sentry/types": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry/types/-/types-8.55.0.tgz", + "integrity": "sha512-6LRT0+r6NWQ+RtllrUW2yQfodST0cJnkOmdpHA75vONgBUhpKwiJ4H7AmgfoTET8w29pU6AnntaGOe0LJbOmog==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.55.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/utils": { + "version": "8.55.0", + "resolved": "https://mirrors.tencent.com/npm/@sentry/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-cYcl39+xcOivBpN9d8ZKbALl+DxZKo/8H0nueJZ0PO4JA+MJGhSm6oHakXxLPaiMoNLTX7yor8ndnQIuFg+vmQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.55.0" + }, + "engines": { + "node": ">=14.18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -10618,6 +10985,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://mirrors.tencent.com/npm/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10873,6 +11246,15 @@ "react-native": "*" } }, + "node_modules/react-native-device-info": { + "version": "14.0.4", + "resolved": "https://mirrors.tencent.com/npm/react-native-device-info/-/react-native-device-info-14.0.4.tgz", + "integrity": "sha512-NX0wMAknSDBeFnEnSFQ8kkAcQrFHrG4Cl0mVjoD+0++iaKrOupiGpBXqs8xR0SeJyPC5zpdPl4h/SaBGly6UxA==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-edge-to-edge": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz", @@ -11132,6 +11514,30 @@ "react-native": ">=0.65.0" } }, + "node_modules/react-native-purchases": { + "version": "9.2.2", + "resolved": "https://mirrors.tencent.com/npm/react-native-purchases/-/react-native-purchases-9.2.2.tgz", + "integrity": "sha512-j376mva8G6SLA2HPTROpUGoivfLMZVWPM7mj2bcgTS8y6NzbyQJ20Npe8V3nWc0N5YFTuknTF8pl0tWc6FqYbA==", + "license": "MIT", + "workspaces": [ + "examples/purchaseTesterTypescript", + "react-native-purchases-ui" + ], + "dependencies": { + "@revenuecat/purchases-js-hybrid-mappings": "16.2.1", + "@revenuecat/purchases-typescript-internal": "16.2.1" + }, + "peerDependencies": { + "react": ">= 16.6.3", + "react-native": ">= 0.73.0", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", diff --git a/package.json b/package.json index 9be136a..e27b037 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", "@reduxjs/toolkit": "^2.8.2", + "@sentry/react-native": "^6.20.0", "cos-js-sdk-v5": "^1.6.0", "dayjs": "^1.11.13", "expo": "~53.0.20", @@ -41,12 +42,14 @@ "react-dom": "19.0.0", "react-native": "0.79.5", "react-native-cos-sdk": "^1.2.1", + "react-native-device-info": "^14.0.4", "react-native-exit-app": "^2.0.0", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", "react-native-image-viewing": "^0.2.2", "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", + "react-native-purchases": "^9.2.2", "react-native-reanimated": "~3.17.4", "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", diff --git a/utils/health.ts b/utils/health.ts index c7b4a50..be9abb5 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -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 { @@ -57,13 +65,20 @@ export async function fetchHealthDataForDate(date: Date): Promise((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((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 { diff --git a/utils/native.utils.ts b/utils/native.utils.ts new file mode 100644 index 0000000..3629398 --- /dev/null +++ b/utils/native.utils.ts @@ -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, + }; +}; \ No newline at end of file diff --git a/utils/sentry.utils.ts b/utils/sentry.utils.ts new file mode 100644 index 0000000..a4d79b2 --- /dev/null +++ b/utils/sentry.utils.ts @@ -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, + 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 +) => { + 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); +}; \ No newline at end of file diff --git a/utils/toast.utils.ts b/utils/toast.utils.ts new file mode 100644 index 0000000..a4d7303 --- /dev/null +++ b/utils/toast.utils.ts @@ -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'); + } + }, +}; \ No newline at end of file