feat(medications): 简化药品添加流程并优化AI相机交互体验
- 移除药品添加选项底部抽屉,直接跳转至AI识别相机 - 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮 - 添加相机引导提示本地存储,避免重复显示 - 修复相机页面布局跳动问题,固定相机高度 - 为医疗免责声明组件添加触觉反馈错误处理 - 实现活动热力图的国际化支持,包括月份标签和统计文本
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}}',
|
||||||
|
|||||||
Reference in New Issue
Block a user