feat(medications): 简化药品添加流程并优化AI相机交互体验
- 移除药品添加选项底部抽屉,直接跳转至AI识别相机 - 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮 - 添加相机引导提示本地存储,避免重复显示 - 修复相机页面布局跳动问题,固定相机高度 - 为医疗免责声明组件添加触觉反馈错误处理 - 实现活动热力图的国际化支持,包括月份标签和统计文本
This commit is contained in:
@@ -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<ReturnType<typeof setTimeout> | 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() {
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenAddSheet}
|
||||
onPress={handleAddMedication}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
@@ -425,13 +420,6 @@ export default function MedicationsScreen() {
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<MedicationAddOptionsSheet
|
||||
visible={addSheetVisible}
|
||||
onClose={() => setAddSheetVisible(false)}
|
||||
onManualAdd={handleManualAdd}
|
||||
onAiRecognize={handleAiRecognize}
|
||||
/>
|
||||
|
||||
{/* 医疗免责声明弹窗 */}
|
||||
<MedicalDisclaimerSheet
|
||||
visible={disclaimerVisible}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { createMedicationRecognitionTask } from '@/services/medications';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
@@ -16,14 +17,27 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
// 本地存储的 key,用于记录用户是否已经看过拍摄引导
|
||||
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
|
||||
|
||||
const captureSteps = [
|
||||
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
|
||||
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
|
||||
@@ -53,21 +67,73 @@ export default function MedicationAiCameraScreen() {
|
||||
});
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||
|
||||
// 动画控制:0 = 圆形拍摄按钮,1 = 展开为两个按钮
|
||||
const expandAnimation = useSharedValue(0);
|
||||
|
||||
// 首次进入时显示引导弹窗
|
||||
useEffect(() => {
|
||||
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<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) {
|
||||
return null;
|
||||
}
|
||||
@@ -234,7 +561,7 @@ export default function MedicationAiCameraScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.cameraCard}>
|
||||
<View style={styles.cameraFrame}>
|
||||
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
|
||||
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.08)']}
|
||||
@@ -300,72 +627,21 @@ export default function MedicationAiCameraScreen() {
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleTakePicture}
|
||||
<AnimatedCaptureButton
|
||||
allRequiredCaptured={allRequiredCaptured}
|
||||
expandAnimation={expandAnimation}
|
||||
onCapture={handleTakePicture}
|
||||
onComplete={handleStartRecognition}
|
||||
disabled={creatingTask}
|
||||
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>
|
||||
loading={creatingTask || uploading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
<AnimatedToggleButton
|
||||
expandAnimation={expandAnimation}
|
||||
onPress={handleToggleCamera}
|
||||
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>
|
||||
|
||||
{/* 只要正面和背面都有照片就显示识别按钮 */}
|
||||
{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>
|
||||
</>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = () => {
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
|
||||
最近6个月活跃 {activityStats.activeDays} 天
|
||||
{t('statistics.activityHeatMap.subtitle', { days: activityStats.activeDays })}
|
||||
</Text>
|
||||
<View style={styles.rightSection}>
|
||||
<View style={[styles.statsBadge, {
|
||||
backgroundColor: 'rgba(122, 90, 248, 0.1)'
|
||||
}]}>
|
||||
<Text style={[styles.statsText, { color: colors.primary }]}>
|
||||
{activityStats.activeRate}%
|
||||
{t('statistics.activityHeatMap.activeRate', { rate: activityStats.activeRate })}
|
||||
</Text>
|
||||
</View>
|
||||
<Popover
|
||||
@@ -184,23 +198,23 @@ const ActivityHeatMap = () => {
|
||||
>
|
||||
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.popoverTitle, { color: colors.text }]}>
|
||||
能量值的积攒后续可以用来兑换 AI 相关权益
|
||||
{t('statistics.activityHeatMap.popover.title')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
|
||||
获取说明
|
||||
{t('statistics.activityHeatMap.popover.subtitle')}
|
||||
</Text>
|
||||
<View style={styles.popoverList}>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
1. 每日登录获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.login')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
2. 每日记录心情获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.mood')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
3. 记饮食获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.diet')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
4. 完成一次目标获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.goal')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -263,7 +277,9 @@ const ActivityHeatMap = () => {
|
||||
|
||||
{/* 图例 */}
|
||||
<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}>
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<View
|
||||
@@ -278,7 +294,9 @@ const ActivityHeatMap = () => {
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>多</Text>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>
|
||||
{t('statistics.activityHeatMap.legend.more')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
Reference in New Issue
Block a user