diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index 434bbb6..9ab3b0c 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -1,6 +1,5 @@ import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation'; import { DateSelector } from '@/components/DateSelector'; -import { MedicationAddOptionsSheet } from '@/components/medication/MedicationAddOptionsSheet'; import { MedicationCard } from '@/components/medication/MedicationCard'; import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack'; import { ThemedText } from '@/components/ThemedText'; @@ -59,7 +58,6 @@ export default function MedicationsScreen() { const celebrationTimerRef = useRef | null>(null); const [isCelebrationVisible, setIsCelebrationVisible] = useState(false); const [disclaimerVisible, setDisclaimerVisible] = useState(false); - const [addSheetVisible, setAddSheetVisible] = useState(false); const [pendingAction, setPendingAction] = useState<'manual' | null>(null); // 从 Redux 获取数据 @@ -72,37 +70,34 @@ export default function MedicationsScreen() { ); const medicationsForDay = useAppSelector(medicationSelector); - const handleOpenAddSheet = useCallback(() => { - setAddSheetVisible(true); - }, []); - - const handleManualAdd = useCallback(() => { - const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY); - setPendingAction('manual'); - - if (hasRead === 'true') { - setAddSheetVisible(false); - setPendingAction(null); - router.push('/medications/add-medication'); - } else { - setAddSheetVisible(false); - setDisclaimerVisible(true); - } - }, []); - - const handleAiRecognize = useCallback(async () => { - setAddSheetVisible(false); + // 直接跳转到 AI 相机页面 + const handleAddMedication = useCallback(async () => { + // 先检查登录状态 const isLoggedIn = await ensureLoggedIn(); if (!isLoggedIn) return; + // 检查 VIP 权限 const access = checkServiceAccess(); if (!access.canUseService) { openMembershipModal(); return; } + // 直接跳转到 AI 相机页面 router.push('/medications/ai-camera'); - }, [checkServiceAccess, ensureLoggedIn, openMembershipModal, router]); + }, [checkServiceAccess, ensureLoggedIn, openMembershipModal]); + + const handleManualAdd = useCallback(() => { + const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY); + setPendingAction('manual'); + + if (hasRead === 'true') { + setPendingAction(null); + router.push('/medications/add-medication'); + } else { + setDisclaimerVisible(true); + } + }, []); const handleDisclaimerConfirm = useCallback(() => { // 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面 @@ -308,7 +303,7 @@ export default function MedicationsScreen() { {isLiquidGlassAvailable() ? ( - setAddSheetVisible(false)} - onManualAdd={handleManualAdd} - onAiRecognize={handleAiRecognize} - /> - {/* 医疗免责声明弹窗 */} { - const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage - if (!hasSeenGuide) { - setShowGuideModal(true); - } + const checkAndShowGuide = async () => { + try { + // 从本地存储读取是否已经看过引导 + const hasSeenGuide = await getItem(MEDICATION_GUIDE_SEEN_KEY); + + // 如果没有看过(返回 null 或 undefined),则显示引导弹窗 + if (!hasSeenGuide) { + setShowGuideModal(true); + // 标记为已看过,下次进入不再自动显示 + await setItem(MEDICATION_GUIDE_SEEN_KEY, 'true'); + } + } catch (error) { + console.error('[MEDICATION_AI] 检查引导状态失败', error); + // 出错时为了更好的用户体验,还是显示引导 + setShowGuideModal(true); + } + }; + + checkAndShowGuide(); }, []); const currentStep = captureSteps[currentStepIndex]; const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri; const allRequiredCaptured = Boolean(shots.front && shots.side); + // 当必需照片都拍摄完成后,触发展开动画 + useEffect(() => { + if (allRequiredCaptured) { + expandAnimation.value = withTiming(1, { + duration: 350, + easing: Easing.out(Easing.cubic), + }); + } else { + expandAnimation.value = withTiming(0, { + duration: 300, + easing: Easing.inOut(Easing.cubic), + }); + } + }, [allRequiredCaptured]); + const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]); + // 计算固定的相机高度,不受按钮状态影响,避免布局跳动 + const cameraHeight = useMemo(() => { + const { height: screenHeight } = Dimensions.get('window'); + + // 计算固定占用的高度(使用最大值确保布局稳定) + const headerHeight = insets.top + 40; // HeaderBar 高度 + const topMetaHeight = 12 + 28 + 26 + 16 + 6; // topMeta 区域:padding + badge + title + subtitle + gap + const shotsRowHeight = 12 + 88; // shotsRow 区域:paddingTop + shotCard 高度 + // 固定使用展开状态的高度,确保布局不会跳动 + const bottomBarHeight = 12 + 86 + 10 + Math.max(insets.bottom, 20); // bottomBar 区域(不包含动态变化部分) + const margins = 12 + 12; // cameraCard 的上下边距 + + // 可用于相机的高度 = 屏幕高度 - 所有固定元素高度 + const availableHeight = screenHeight - headerHeight - topMetaHeight - shotsRowHeight - bottomBarHeight - margins; + + // 确保最小高度为 300,最大不超过屏幕的 50% + return Math.max(300, Math.min(availableHeight, screenHeight * 0.5)); + }, [insets.top, insets.bottom]); + const handleToggleCamera = () => { setFacing((prev) => (prev === 'back' ? 'front' : 'back')); }; @@ -167,6 +233,267 @@ export default function MedicationAiCameraScreen() { } }; + // 动画翻转按钮组件 + const AnimatedToggleButton = ({ + expandAnimation, + onPress, + disabled, + }: { + expandAnimation: SharedValue; + onPress: () => void; + disabled: boolean; + }) => { + // 翻转按钮的位置动画 - 展开时向右移出 + const toggleButtonStyle = useAnimatedStyle(() => { + const translateX = interpolate( + expandAnimation.value, + [0, 1], + [0, 100], // 向右移出屏幕 + Extrapolation.CLAMP + ); + const opacity = interpolate( + expandAnimation.value, + [0, 0.3], + [1, 0], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateX }], + }; + }); + + return ( + + + {isLiquidGlassAvailable() ? ( + + + 翻转 + + ) : ( + + + 翻转 + + )} + + + ); + }; + + // 动画拍摄按钮组件 + const AnimatedCaptureButton = ({ + allRequiredCaptured, + expandAnimation, + onCapture, + onComplete, + disabled, + loading, + }: { + allRequiredCaptured: boolean; + expandAnimation: SharedValue; + onCapture: () => void; + onComplete: () => void; + disabled: boolean; + loading: boolean; + }) => { + // 单个拍摄按钮的缩放和透明度动画 + const singleButtonStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + expandAnimation.value, + [0, 0.3], + [1, 0], + Extrapolation.CLAMP + ), + transform: [{ + scale: interpolate( + expandAnimation.value, + [0, 0.3], + [1, 0.8], + Extrapolation.CLAMP + ) + }], + })); + + // 左侧按钮的位置和透明度动画 + const leftButtonStyle = useAnimatedStyle(() => { + const translateX = interpolate( + expandAnimation.value, + [0, 1], + [0, -70], // 向左移动更多距离 + Extrapolation.CLAMP + ); + const opacity = interpolate( + expandAnimation.value, + [0.4, 1], + [0, 1], + Extrapolation.CLAMP + ); + const scale = interpolate( + expandAnimation.value, + [0.4, 1], + [0.8, 1], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateX }, { scale }], + }; + }); + + // 右侧按钮的位置和透明度动画 + const rightButtonStyle = useAnimatedStyle(() => { + const translateX = interpolate( + expandAnimation.value, + [0, 1], + [0, 70], // 向右移动更多距离 + Extrapolation.CLAMP + ); + const opacity = interpolate( + expandAnimation.value, + [0.4, 1], + [0, 1], + Extrapolation.CLAMP + ); + const scale = interpolate( + expandAnimation.value, + [0.4, 1], + [0.8, 1], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateX }, { scale }], + }; + }); + + // 容器整体向右平移的动画 + const containerStyle = useAnimatedStyle(() => { + const translateX = interpolate( + expandAnimation.value, + [0, 1], + [0, 60], // 整体向右移动更多,与相册按钮保持距离 + Extrapolation.CLAMP + ); + return { + transform: [{ translateX }], + }; + }); + + return ( + + {/* 未展开状态:圆形拍摄按钮 */} + {!allRequiredCaptured && ( + + + {isLiquidGlassAvailable() ? ( + + + + + + ) : ( + + + + + + )} + + + )} + + {/* 展开状态:两个分离的按钮 */} + {allRequiredCaptured && ( + <> + {/* 左侧:拍照按钮 */} + + + {isLiquidGlassAvailable() ? ( + + + 拍照 + + ) : ( + + + 拍照 + + )} + + + + {/* 右侧:完成按钮 */} + + + {isLiquidGlassAvailable() ? ( + + {loading ? ( + + ) : ( + <> + + 完成 + + )} + + ) : ( + + {loading ? ( + + ) : ( + <> + + 完成 + + )} + + )} + + + + )} + + ); + }; + if (!permission) { return null; } @@ -234,7 +561,7 @@ export default function MedicationAiCameraScreen() { - + - - {isLiquidGlassAvailable() ? ( - - - - - - ) : ( - - - - - - )} - + loading={creatingTask || uploading} + /> - - {isLiquidGlassAvailable() ? ( - - - 翻转 - - ) : ( - - - 翻转 - - )} - + /> - - {/* 只要正面和背面都有照片就显示识别按钮 */} - {allRequiredCaptured && ( - - {creatingTask || uploading ? ( - - ) : ( - - 开始识别 - - )} - - )} @@ -496,41 +772,82 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', }, + captureButtonContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + height: 64, + }, + singleCaptureWrapper: { + position: 'absolute', + }, captureBtn: { - width: 86, - height: 86, - borderRadius: 43, + width: 64, + height: 64, + borderRadius: 32, justifyContent: 'center', alignItems: 'center', overflow: 'hidden', shadowColor: '#0ea5e9', shadowOpacity: 0.25, - shadowRadius: 16, - shadowOffset: { width: 0, height: 8 }, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, }, fallbackCaptureBtn: { backgroundColor: 'rgba(255, 255, 255, 0.95)', - borderWidth: 3, + borderWidth: 2, borderColor: 'rgba(14, 165, 233, 0.2)', }, captureOuterRing: { - width: 76, - height: 76, - borderRadius: 38, + width: 56, + height: 56, + borderRadius: 28, backgroundColor: 'rgba(255, 255, 255, 0.15)', justifyContent: 'center', alignItems: 'center', }, captureInner: { - width: 60, - height: 60, - borderRadius: 30, + width: 44, + height: 44, + borderRadius: 22, backgroundColor: '#fff', shadowColor: '#0ea5e9', shadowOpacity: 0.4, - shadowRadius: 8, + shadowRadius: 6, shadowOffset: { width: 0, height: 2 }, }, + splitButtonWrapper: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + }, + splitButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingHorizontal: 20, + paddingVertical: 11, + borderRadius: 16, + overflow: 'hidden', + width: 110, + height: 48, + shadowColor: '#0f172a', + shadowOpacity: 0.1, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + }, + fallbackSplitButton: { + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderWidth: 1, + borderColor: 'rgba(15, 23, 42, 0.1)', + }, + splitButtonLabel: { + fontSize: 13, + fontWeight: '600', + color: '#0f172a', + }, secondaryBtn: { flexDirection: 'row', alignItems: 'center', diff --git a/components/ActivityHeatMap.tsx b/components/ActivityHeatMap.tsx index 630a5b6..60ee651 100644 --- a/components/ActivityHeatMap.tsx +++ b/components/ActivityHeatMap.tsx @@ -2,6 +2,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol'; import { Colors } from '@/constants/Colors'; import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useI18n } from '@/hooks/useI18n'; import dayjs from 'dayjs'; import React, { useMemo, useState } from 'react'; import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; @@ -12,6 +13,7 @@ const ActivityHeatMap = () => { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; const [showPopover, setShowPopover] = useState(false); + const { t } = useI18n(); const activityData = useAppSelector(stat => stat.user.activityHistory); @@ -103,8 +105,20 @@ const ActivityHeatMap = () => { // 获取月份标签(简化的月份标签系统) const getMonthLabels = useMemo(() => { - const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', - '7月', '8月', '9月', '10月', '11月', '12月']; + const monthNames = [ + t('statistics.activityHeatMap.months.1'), + t('statistics.activityHeatMap.months.2'), + t('statistics.activityHeatMap.months.3'), + t('statistics.activityHeatMap.months.4'), + t('statistics.activityHeatMap.months.5'), + t('statistics.activityHeatMap.months.6'), + t('statistics.activityHeatMap.months.7'), + t('statistics.activityHeatMap.months.8'), + t('statistics.activityHeatMap.months.9'), + t('statistics.activityHeatMap.months.10'), + t('statistics.activityHeatMap.months.11'), + t('statistics.activityHeatMap.months.12'), + ]; // 简单策略:均匀分布4-5个月份标签 const totalWeeks = weeksToShow; @@ -130,7 +144,7 @@ const ActivityHeatMap = () => { }); return labelPositions; - }, [organizeDataByWeeks, weeksToShow]); + }, [organizeDataByWeeks, weeksToShow, t]); // 计算活动统计 const activityStats = useMemo(() => { @@ -156,14 +170,14 @@ const ActivityHeatMap = () => { - 最近6个月活跃 {activityStats.activeDays} 天 + {t('statistics.activityHeatMap.subtitle', { days: activityStats.activeDays })} - {activityStats.activeRate}% + {t('statistics.activityHeatMap.activeRate', { rate: activityStats.activeRate })} { > - 能量值的积攒后续可以用来兑换 AI 相关权益 + {t('statistics.activityHeatMap.popover.title')} - 获取说明 + {t('statistics.activityHeatMap.popover.subtitle')} - 1. 每日登录获得能量值+1 + {t('statistics.activityHeatMap.popover.rules.login')} - 2. 每日记录心情获得能量值+1 + {t('statistics.activityHeatMap.popover.rules.mood')} - 3. 记饮食获得能量值+1 + {t('statistics.activityHeatMap.popover.rules.diet')} - 4. 完成一次目标获得能量值+1 + {t('statistics.activityHeatMap.popover.rules.goal')} @@ -263,7 +277,9 @@ const ActivityHeatMap = () => { {/* 图例 */} - + + {t('statistics.activityHeatMap.legend.less')} + {[0, 1, 2, 3, 4].map((level) => ( { /> ))} - + + {t('statistics.activityHeatMap.legend.more')} + ); diff --git a/components/ui/MedicalDisclaimerSheet.tsx b/components/ui/MedicalDisclaimerSheet.tsx index c528d8c..2a68549 100644 --- a/components/ui/MedicalDisclaimerSheet.tsx +++ b/components/ui/MedicalDisclaimerSheet.tsx @@ -88,13 +88,19 @@ export function MedicalDisclaimerSheet({ }, [visible, modalVisible, backdropOpacity, translateY]); const handleCancel = () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + // 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿 + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => { + console.warn('[MEDICATION] Haptic feedback failed:', error); + }); onClose(); }; const handleConfirm = () => { if (loading) return; - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + // 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿 + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => { + console.warn('[MEDICATION] Haptic feedback failed:', error); + }); onConfirm(); }; diff --git a/i18n/index.ts b/i18n/index.ts index 868a55f..295ccd6 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -459,6 +459,38 @@ const statisticsResources = { challenges: '挑战', personal: '个人', }, + activityHeatMap: { + subtitle: '最近6个月活跃 {{days}} 天', + activeRate: '{{rate}}%', + popover: { + title: '能量值的积攒后续可以用来兑换 AI 相关权益', + subtitle: '获取说明', + rules: { + login: '1. 每日登录获得能量值+1', + mood: '2. 每日记录心情获得能量值+1', + diet: '3. 记饮食获得能量值+1', + goal: '4. 完成一次目标获得能量值+1', + }, + }, + months: { + 1: '1月', + 2: '2月', + 3: '3月', + 4: '4月', + 5: '5月', + 6: '6月', + 7: '7月', + 8: '8月', + 9: '9月', + 10: '10月', + 11: '11月', + 12: '12月', + }, + legend: { + less: '少', + more: '多', + }, + }, }; const medicationsResources = { @@ -1253,6 +1285,38 @@ const resources = { challenges: 'Challenges', personal: 'Me', }, + activityHeatMap: { + subtitle: 'Active {{days}} days in the last 6 months', + activeRate: '{{rate}}%', + popover: { + title: 'Accumulated energy can be redeemed for AI-related benefits', + subtitle: 'How to earn', + rules: { + login: '1. Daily login earns energy +1', + mood: '2. Daily mood record earns energy +1', + diet: '3. Diet record earns energy +1', + goal: '4. Complete a goal earns energy +1', + }, + }, + months: { + 1: 'Jan', + 2: 'Feb', + 3: 'Mar', + 4: 'Apr', + 5: 'May', + 6: 'Jun', + 7: 'Jul', + 8: 'Aug', + 9: 'Sep', + 10: 'Oct', + 11: 'Nov', + 12: 'Dec', + }, + legend: { + less: 'Less', + more: 'More', + }, + }, }, medications: { greeting: 'Hello, {{name}}',