feat(medications): 简化药品添加流程并优化AI相机交互体验

- 移除药品添加选项底部抽屉,直接跳转至AI识别相机
- 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮
- 添加相机引导提示本地存储,避免重复显示
- 修复相机页面布局跳动问题,固定相机高度
- 为医疗免责声明组件添加触觉反馈错误处理
- 实现活动热力图的国际化支持,包括月份标签和统计文本
This commit is contained in:
richarjiang
2025-11-25 14:09:24 +08:00
parent 3db2d39a58
commit 6f2b7eb45e
5 changed files with 518 additions and 125 deletions

View File

@@ -1,6 +1,5 @@
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation'; import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import { MedicationAddOptionsSheet } from '@/components/medication/MedicationAddOptionsSheet';
import { MedicationCard } from '@/components/medication/MedicationCard'; import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack'; import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
@@ -59,7 +58,6 @@ export default function MedicationsScreen() {
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false); const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false); const [disclaimerVisible, setDisclaimerVisible] = useState(false);
const [addSheetVisible, setAddSheetVisible] = useState(false);
const [pendingAction, setPendingAction] = useState<'manual' | null>(null); const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
// 从 Redux 获取数据 // 从 Redux 获取数据
@@ -72,37 +70,34 @@ export default function MedicationsScreen() {
); );
const medicationsForDay = useAppSelector(medicationSelector); const medicationsForDay = useAppSelector(medicationSelector);
const handleOpenAddSheet = useCallback(() => { // 直接跳转到 AI 相机页面
setAddSheetVisible(true); const handleAddMedication = useCallback(async () => {
}, []); // 先检查登录状态
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);
const isLoggedIn = await ensureLoggedIn(); const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return; if (!isLoggedIn) return;
// 检查 VIP 权限
const access = checkServiceAccess(); const access = checkServiceAccess();
if (!access.canUseService) { if (!access.canUseService) {
openMembershipModal(); openMembershipModal();
return; return;
} }
// 直接跳转到 AI 相机页面
router.push('/medications/ai-camera'); 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(() => { const handleDisclaimerConfirm = useCallback(() => {
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面 // 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
@@ -308,7 +303,7 @@ export default function MedicationsScreen() {
<TouchableOpacity <TouchableOpacity
activeOpacity={0.7} activeOpacity={0.7}
onPress={handleOpenAddSheet} onPress={handleAddMedication}
> >
{isLiquidGlassAvailable() ? ( {isLiquidGlassAvailable() ? (
<GlassView <GlassView
@@ -425,13 +420,6 @@ export default function MedicationsScreen() {
)} )}
</ScrollView> </ScrollView>
<MedicationAddOptionsSheet
visible={addSheetVisible}
onClose={() => setAddSheetVisible(false)}
onManualAdd={handleManualAdd}
onAiRecognize={handleAiRecognize}
/>
{/* 医疗免责声明弹窗 */} {/* 医疗免责声明弹窗 */}
<MedicalDisclaimerSheet <MedicalDisclaimerSheet
visible={disclaimerVisible} visible={disclaimerVisible}

View File

@@ -5,6 +5,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { createMedicationRecognitionTask } from '@/services/medications'; import { createMedicationRecognitionTask } from '@/services/medications';
import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
@@ -16,14 +17,27 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Dimensions,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import Animated, {
Easing,
Extrapolation,
interpolate,
SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 本地存储的 key用于记录用户是否已经看过拍摄引导
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
const captureSteps = [ const captureSteps = [
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true }, { key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true }, { key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
@@ -54,20 +68,72 @@ export default function MedicationAiCameraScreen() {
const [creatingTask, setCreatingTask] = useState(false); const [creatingTask, setCreatingTask] = useState(false);
const [showGuideModal, setShowGuideModal] = useState(false); const [showGuideModal, setShowGuideModal] = useState(false);
// 动画控制0 = 圆形拍摄按钮1 = 展开为两个按钮
const expandAnimation = useSharedValue(0);
// 首次进入时显示引导弹窗 // 首次进入时显示引导弹窗
useEffect(() => { useEffect(() => {
const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage const checkAndShowGuide = async () => {
if (!hasSeenGuide) { try {
setShowGuideModal(true); // 从本地存储读取是否已经看过引导
} 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 currentStep = captureSteps[currentStepIndex];
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri; const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
const allRequiredCaptured = Boolean(shots.front && shots.side); 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 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 = () => { const handleToggleCamera = () => {
setFacing((prev) => (prev === 'back' ? 'front' : 'back')); setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
}; };
@@ -167,6 +233,267 @@ export default function MedicationAiCameraScreen() {
} }
}; };
// 动画翻转按钮组件
const AnimatedToggleButton = ({
expandAnimation,
onPress,
disabled,
}: {
expandAnimation: SharedValue<number>;
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 (
<Animated.View style={toggleButtonStyle}>
<TouchableOpacity
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</View>
)}
</TouchableOpacity>
</Animated.View>
);
};
// 动画拍摄按钮组件
const AnimatedCaptureButton = ({
allRequiredCaptured,
expandAnimation,
onCapture,
onComplete,
disabled,
loading,
}: {
allRequiredCaptured: boolean;
expandAnimation: SharedValue<number>;
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 (
<Animated.View style={[styles.captureButtonContainer, containerStyle]}>
{/* 未展开状态:圆形拍摄按钮 */}
{!allRequiredCaptured && (
<Animated.View style={[styles.singleCaptureWrapper, singleButtonStyle]}>
<TouchableOpacity
onPress={onCapture}
disabled={disabled}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.captureBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.8)"
isInteractive={true}
>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</GlassView>
) : (
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</View>
)}
</TouchableOpacity>
</Animated.View>
)}
{/* 展开状态:两个分离的按钮 */}
{allRequiredCaptured && (
<>
{/* 左侧:拍照按钮 */}
<Animated.View style={[styles.splitButtonWrapper, leftButtonStyle]}>
<TouchableOpacity
onPress={onCapture}
disabled={disabled}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.splitButton}
glassEffectStyle="clear"
tintColor="rgba(14, 165, 233, 0.2)"
isInteractive={true}
>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
</GlassView>
) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
</View>
)}
</TouchableOpacity>
</Animated.View>
{/* 右侧:完成按钮 */}
<Animated.View style={[styles.splitButtonWrapper, rightButtonStyle]}>
<TouchableOpacity
onPress={onComplete}
disabled={disabled || loading}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.splitButton}
glassEffectStyle="clear"
tintColor="rgba(16, 185, 129, 0.2)"
isInteractive={true}
>
{loading ? (
<ActivityIndicator size="small" color="#10b981" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
</>
)}
</GlassView>
) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
{loading ? (
<ActivityIndicator size="small" color="#10b981" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
</>
)}
</View>
)}
</TouchableOpacity>
</Animated.View>
</>
)}
</Animated.View>
);
};
if (!permission) { if (!permission) {
return null; return null;
} }
@@ -234,7 +561,7 @@ export default function MedicationAiCameraScreen() {
</View> </View>
<View style={styles.cameraCard}> <View style={styles.cameraCard}>
<View style={styles.cameraFrame}> <View style={[styles.cameraFrame, { height: cameraHeight }]}>
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} /> <CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
<LinearGradient <LinearGradient
colors={['transparent', 'rgba(0,0,0,0.08)']} colors={['transparent', 'rgba(0,0,0,0.08)']}
@@ -300,72 +627,21 @@ export default function MedicationAiCameraScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <AnimatedCaptureButton
onPress={handleTakePicture} allRequiredCaptured={allRequiredCaptured}
expandAnimation={expandAnimation}
onCapture={handleTakePicture}
onComplete={handleStartRecognition}
disabled={creatingTask} disabled={creatingTask}
activeOpacity={0.8} loading={creatingTask || uploading}
> />
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.captureBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.8)"
isInteractive={true}
>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</GlassView>
) : (
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</View>
)}
</TouchableOpacity>
<TouchableOpacity <AnimatedToggleButton
expandAnimation={expandAnimation}
onPress={handleToggleCamera} onPress={handleToggleCamera}
disabled={creatingTask} disabled={creatingTask}
activeOpacity={0.7} />
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</View>
)}
</TouchableOpacity>
</View> </View>
{/* 只要正面和背面都有照片就显示识别按钮 */}
{allRequiredCaptured && (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleStartRecognition}
disabled={creatingTask || uploading}
style={[styles.primaryCta, { backgroundColor: colors.primary }]}
>
{creatingTask || uploading ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<Text style={[styles.primaryText, { color: colors.onPrimary }]}>
</Text>
)}
</TouchableOpacity>
)}
</View> </View>
</View> </View>
</> </>
@@ -496,41 +772,82 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
}, },
captureButtonContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
height: 64,
},
singleCaptureWrapper: {
position: 'absolute',
},
captureBtn: { captureBtn: {
width: 86, width: 64,
height: 86, height: 64,
borderRadius: 43, borderRadius: 32,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
overflow: 'hidden', overflow: 'hidden',
shadowColor: '#0ea5e9', shadowColor: '#0ea5e9',
shadowOpacity: 0.25, shadowOpacity: 0.25,
shadowRadius: 16, shadowRadius: 12,
shadowOffset: { width: 0, height: 8 }, shadowOffset: { width: 0, height: 6 },
}, },
fallbackCaptureBtn: { fallbackCaptureBtn: {
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderWidth: 3, borderWidth: 2,
borderColor: 'rgba(14, 165, 233, 0.2)', borderColor: 'rgba(14, 165, 233, 0.2)',
}, },
captureOuterRing: { captureOuterRing: {
width: 76, width: 56,
height: 76, height: 56,
borderRadius: 38, borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.15)', backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
captureInner: { captureInner: {
width: 60, width: 44,
height: 60, height: 44,
borderRadius: 30, borderRadius: 22,
backgroundColor: '#fff', backgroundColor: '#fff',
shadowColor: '#0ea5e9', shadowColor: '#0ea5e9',
shadowOpacity: 0.4, shadowOpacity: 0.4,
shadowRadius: 8, shadowRadius: 6,
shadowOffset: { width: 0, height: 2 }, 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: { secondaryBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -2,6 +2,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
@@ -12,6 +13,7 @@ const ActivityHeatMap = () => {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light']; const colors = Colors[colorScheme ?? 'light'];
const [showPopover, setShowPopover] = useState(false); const [showPopover, setShowPopover] = useState(false);
const { t } = useI18n();
const activityData = useAppSelector(stat => stat.user.activityHistory); const activityData = useAppSelector(stat => stat.user.activityHistory);
@@ -103,8 +105,20 @@ const ActivityHeatMap = () => {
// 获取月份标签(简化的月份标签系统) // 获取月份标签(简化的月份标签系统)
const getMonthLabels = useMemo(() => { const getMonthLabels = useMemo(() => {
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', const monthNames = [
'7月', '8月', '9月', '10月', '11月', '12月']; 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个月份标签 // 简单策略均匀分布4-5个月份标签
const totalWeeks = weeksToShow; const totalWeeks = weeksToShow;
@@ -130,7 +144,7 @@ const ActivityHeatMap = () => {
}); });
return labelPositions; return labelPositions;
}, [organizeDataByWeeks, weeksToShow]); }, [organizeDataByWeeks, weeksToShow, t]);
// 计算活动统计 // 计算活动统计
const activityStats = useMemo(() => { const activityStats = useMemo(() => {
@@ -156,14 +170,14 @@ const ActivityHeatMap = () => {
<View style={styles.header}> <View style={styles.header}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text style={[styles.subtitle, { color: colors.textMuted }]}> <Text style={[styles.subtitle, { color: colors.textMuted }]}>
6 {activityStats.activeDays} {t('statistics.activityHeatMap.subtitle', { days: activityStats.activeDays })}
</Text> </Text>
<View style={styles.rightSection}> <View style={styles.rightSection}>
<View style={[styles.statsBadge, { <View style={[styles.statsBadge, {
backgroundColor: 'rgba(122, 90, 248, 0.1)' backgroundColor: 'rgba(122, 90, 248, 0.1)'
}]}> }]}>
<Text style={[styles.statsText, { color: colors.primary }]}> <Text style={[styles.statsText, { color: colors.primary }]}>
{activityStats.activeRate}% {t('statistics.activityHeatMap.activeRate', { rate: activityStats.activeRate })}
</Text> </Text>
</View> </View>
<Popover <Popover
@@ -184,23 +198,23 @@ const ActivityHeatMap = () => {
> >
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}> <View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
<Text style={[styles.popoverTitle, { color: colors.text }]}> <Text style={[styles.popoverTitle, { color: colors.text }]}>
AI {t('statistics.activityHeatMap.popover.title')}
</Text> </Text>
<Text style={[styles.popoverSubtitle, { color: colors.text }]}> <Text style={[styles.popoverSubtitle, { color: colors.text }]}>
{t('statistics.activityHeatMap.popover.subtitle')}
</Text> </Text>
<View style={styles.popoverList}> <View style={styles.popoverList}>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}> <Text style={[styles.popoverItem, { color: colors.textMuted }]}>
1. +1 {t('statistics.activityHeatMap.popover.rules.login')}
</Text> </Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}> <Text style={[styles.popoverItem, { color: colors.textMuted }]}>
2. +1 {t('statistics.activityHeatMap.popover.rules.mood')}
</Text> </Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}> <Text style={[styles.popoverItem, { color: colors.textMuted }]}>
3. +1 {t('statistics.activityHeatMap.popover.rules.diet')}
</Text> </Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}> <Text style={[styles.popoverItem, { color: colors.textMuted }]}>
4. +1 {t('statistics.activityHeatMap.popover.rules.goal')}
</Text> </Text>
</View> </View>
</View> </View>
@@ -263,7 +277,9 @@ const ActivityHeatMap = () => {
{/* 图例 */} {/* 图例 */}
<View style={styles.legend}> <View style={styles.legend}>
<Text style={[styles.legendText, { color: colors.textMuted }]}></Text> <Text style={[styles.legendText, { color: colors.textMuted }]}>
{t('statistics.activityHeatMap.legend.less')}
</Text>
<View style={styles.legendColors}> <View style={styles.legendColors}>
{[0, 1, 2, 3, 4].map((level) => ( {[0, 1, 2, 3, 4].map((level) => (
<View <View
@@ -278,7 +294,9 @@ const ActivityHeatMap = () => {
/> />
))} ))}
</View> </View>
<Text style={[styles.legendText, { color: colors.textMuted }]}></Text> <Text style={[styles.legendText, { color: colors.textMuted }]}>
{t('statistics.activityHeatMap.legend.more')}
</Text>
</View> </View>
</View> </View>
); );

View File

@@ -88,13 +88,19 @@ export function MedicalDisclaimerSheet({
}, [visible, modalVisible, backdropOpacity, translateY]); }, [visible, modalVisible, backdropOpacity, translateY]);
const handleCancel = () => { const handleCancel = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); // 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => {
console.warn('[MEDICATION] Haptic feedback failed:', error);
});
onClose(); onClose();
}; };
const handleConfirm = () => { const handleConfirm = () => {
if (loading) return; if (loading) return;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => {
console.warn('[MEDICATION] Haptic feedback failed:', error);
});
onConfirm(); onConfirm();
}; };

View File

@@ -459,6 +459,38 @@ const statisticsResources = {
challenges: '挑战', challenges: '挑战',
personal: '个人', 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 = { const medicationsResources = {
@@ -1253,6 +1285,38 @@ const resources = {
challenges: 'Challenges', challenges: 'Challenges',
personal: 'Me', 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: { medications: {
greeting: 'Hello, {{name}}', greeting: 'Hello, {{name}}',