diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx
index ce12cb4..d598f91 100644
--- a/app/(tabs)/medications.tsx
+++ b/app/(tabs)/medications.tsx
@@ -5,6 +5,7 @@ import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsS
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
+import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -59,6 +60,7 @@ export default function MedicationsScreen() {
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
+ const [aiSummaryInfoVisible, setAiSummaryInfoVisible] = useState(false);
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
@@ -115,6 +117,33 @@ export default function MedicationsScreen() {
setPendingAction(null);
}, []);
+ const handleOpenAiSummary = useCallback(async () => {
+ // 先检查登录状态
+ const isLoggedIn = await ensureLoggedIn();
+ if (!isLoggedIn) return;
+
+ // 检查 VIP 权限
+ const access = checkServiceAccess();
+ if (!access.canUseService) {
+ // 非会员显示介绍弹窗
+ setAiSummaryInfoVisible(true);
+ return;
+ }
+
+ // 会员直接跳转到 AI 总结页面
+ router.push('/medications/ai-summary');
+ }, [checkServiceAccess, ensureLoggedIn]);
+
+ const handleAiSummaryInfoConfirm = useCallback(() => {
+ setAiSummaryInfoVisible(false);
+ // 点击"我要订阅"后,弹出会员订阅弹窗
+ openMembershipModal();
+ }, [openMembershipModal]);
+
+ const handleAiSummaryInfoClose = useCallback(() => {
+ setAiSummaryInfoVisible(false);
+ }, []);
+
const handleOpenMedicationManagement = useCallback(() => {
router.push('/medications/manage-medications');
}, []);
@@ -285,31 +314,59 @@ export default function MedicationsScreen() {
-
- {isLiquidGlassAvailable() ? (
+ {isLiquidGlassAvailable() ? (
+
-
+
- ) : (
-
-
-
- )}
-
+
+ ) : (
+
+
+
+ )}
-
- {isLiquidGlassAvailable() ? (
+ {isLiquidGlassAvailable() ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {isLiquidGlassAvailable() ? (
+
- ) : (
-
-
-
- )}
-
+
+ ) : (
+
+
+
+ )}
@@ -430,6 +491,13 @@ export default function MedicationsScreen() {
onClose={handleDisclaimerClose}
onConfirm={handleDisclaimerConfirm}
/>
+
+ {/* AI 用药总结介绍弹窗 */}
+
);
}
diff --git a/app/medications/ai-summary.tsx b/app/medications/ai-summary.tsx
new file mode 100644
index 0000000..09aa7b4
--- /dev/null
+++ b/app/medications/ai-summary.tsx
@@ -0,0 +1,886 @@
+import { ThemedText } from '@/components/ThemedText';
+import { HeaderBar } from '@/components/ui/HeaderBar';
+import { IconSymbol } from '@/components/ui/IconSymbol';
+import { getMedicationAiSummary } from '@/services/medications';
+import { type MedicationAiSummary, type MedicationAiSummaryItem } from '@/types/medication';
+import { useFocusEffect } from '@react-navigation/native';
+import dayjs from 'dayjs';
+import { LinearGradient } from 'expo-linear-gradient';
+import React, { useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ ActivityIndicator,
+ Modal,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+export default function MedicationAiSummaryScreen() {
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+
+ const [summary, setSummary] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [lastUpdated, setLastUpdated] = useState('');
+ const [showInfoModal, setShowInfoModal] = useState(false);
+ const [showCompletionInfoModal, setShowCompletionInfoModal] = useState(false);
+
+ const fetchSummary = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await getMedicationAiSummary();
+ setSummary(data);
+ setLastUpdated(dayjs().format('YYYY.MM.DD HH:mm'));
+ } catch (err: any) {
+ const status = err?.status;
+ if (status === 403) {
+ setError(t('medications.aiSummary.error403'));
+ } else {
+ setError(err?.message || t('medications.aiSummary.genericError'));
+ }
+ setSummary(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [t]);
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchSummary();
+ }, [fetchSummary])
+ );
+
+ const handleExplainRefresh = useCallback(() => {
+ setShowInfoModal(true);
+ }, []);
+
+ const handleExplainCompletion = useCallback(() => {
+ setShowCompletionInfoModal(true);
+ }, []);
+
+ const medicationItems = summary?.medicationAnalysis ?? [];
+ const isEmpty = !loading && !error && medicationItems.length === 0;
+
+ const stats = useMemo(() => {
+ const plannedDoses = medicationItems.reduce((acc, item) => acc + (item.plannedDoses || 0), 0);
+ const takenDoses = medicationItems.reduce((acc, item) => acc + (item.takenDoses || 0), 0);
+ const completion = plannedDoses > 0 ? takenDoses / plannedDoses : 0;
+ const avgCompletion =
+ medicationItems.length > 0
+ ? medicationItems.reduce((acc, item) => acc + (item.completionRate || 0), 0) /
+ medicationItems.length
+ : 0;
+ const plannedDays = medicationItems.reduce((acc, item) => acc + (item.plannedDays || 0), 0);
+
+ return {
+ plannedDoses,
+ takenDoses,
+ completion,
+ avgCompletion,
+ plannedDays,
+ activePlans: medicationItems.length,
+ };
+ }, [medicationItems]);
+
+ const completionPercent = Math.min(100, Math.round(stats.completion * 100));
+
+ const renderMedicationCard = (item: MedicationAiSummaryItem) => {
+ const percent = Math.min(100, Math.round((item.completionRate || 0) * 100));
+ return (
+
+
+
+ {item.name}
+
+ {t('medications.aiSummary.daysLabel', {
+ days: item.plannedDays,
+ times: item.timesPerDay,
+ })}
+
+
+
+
+
+ {t('medications.aiSummary.badges.adherence')}
+
+
+
+
+
+
+
+
+
+ {t('medications.aiSummary.completionLabel', { value: percent })}
+
+
+
+
+
+ {t('medications.aiSummary.doseSummary', {
+ taken: item.takenDoses,
+ planned: item.plannedDoses,
+ })}
+
+
+ {dayjs(item.startDate).format('YYYY.MM.DD')}
+
+
+
+ );
+ };
+
+ const headerTitle = (
+
+ {t('medications.aiSummary.title')}
+ {t('medications.aiSummary.subtitle')}
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ {t('medications.aiSummary.overviewTitle')}
+
+
+ {lastUpdated ? t('medications.aiSummary.updatedAt', { time: lastUpdated }) : ' '}
+
+
+
+
+
+ {completionPercent}%
+
+ {t('medications.aiSummary.doseSummary', {
+ taken: stats.takenDoses,
+ planned: stats.plannedDoses,
+ })}
+
+
+
+
+
+
+
+ {t('medications.aiSummary.badges.safety')}
+
+ {stats.activePlans}
+
+ {t('medications.aiSummary.stats.activePlans')}
+
+
+
+
+
+
+
+ {t('medications.aiSummary.stats.avgCompletion')}
+
+
+ {Math.round(stats.avgCompletion * 100)}%
+
+
+
+
+ {t('medications.aiSummary.stats.activeDays')}
+
+ {stats.plannedDays}
+
+
+
+ {t('medications.aiSummary.stats.takenDoses')}
+
+ {stats.takenDoses}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ {t('medications.aiSummary.retry')}
+
+
+ ) : (
+ <>
+
+
+
+ {t('medications.aiSummary.keyInsights')}
+
+
+
+
+ {t('medications.aiSummary.pillChip')}
+
+
+
+
+ {summary?.keyInsights || t('medications.aiSummary.keyInsightPlaceholder')}
+
+
+
+
+
+
+ {t('medications.aiSummary.listTitle')}
+
+
+
+
+
+ {loading ? (
+
+
+
+ {t('medications.aiSummary.refresh')}
+
+
+ ) : isEmpty ? (
+
+
+ {t('medications.aiSummary.emptyTitle')}
+
+
+ {t('medications.aiSummary.emptyDescription')}
+
+
+ ) : (
+ {medicationItems.map(renderMedicationCard)}
+ )}
+
+ >
+ )}
+
+
+ setShowInfoModal(false)}
+ >
+ setShowInfoModal(false)}
+ >
+ e.stopPropagation()}
+ style={styles.infoModal}
+ >
+
+
+ {t('medications.aiSummary.infoModal.badge')}
+ {t('medications.aiSummary.infoModal.title')}
+ setShowInfoModal(false)}
+ style={styles.infoClose}
+ accessibilityLabel="close"
+ >
+
+
+
+
+
+ {t('medications.aiSummary.infoModal.point1')}
+
+
+ {t('medications.aiSummary.infoModal.point2')}
+
+
+ {t('medications.aiSummary.infoModal.point3')}
+
+
+ {t('medications.aiSummary.infoModal.point4')}
+
+
+
+ setShowInfoModal(false)}
+ >
+
+ {t('medications.aiSummary.infoModal.button')}
+
+
+
+
+
+
+
+
+ setShowCompletionInfoModal(false)}
+ >
+ setShowCompletionInfoModal(false)}
+ >
+ e.stopPropagation()}
+ style={styles.infoModal}
+ >
+
+
+ {t('medications.aiSummary.completionInfoModal.badge')}
+ {t('medications.aiSummary.completionInfoModal.title')}
+ setShowCompletionInfoModal(false)}
+ style={styles.infoClose}
+ accessibilityLabel="close"
+ >
+
+
+
+
+
+ {t('medications.aiSummary.completionInfoModal.point1')}
+
+
+ {t('medications.aiSummary.completionInfoModal.point2')}
+
+
+ {t('medications.aiSummary.completionInfoModal.point3')}
+
+
+ {t('medications.aiSummary.completionInfoModal.point4')}
+
+
+ {t('medications.aiSummary.completionInfoModal.point5')}
+
+
+
+ setShowCompletionInfoModal(false)}
+ >
+
+ {t('medications.aiSummary.completionInfoModal.button')}
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#0b0f16',
+ },
+ scrollContent: {
+ paddingHorizontal: 20,
+ gap: 20,
+ },
+ glowTop: {
+ position: 'absolute',
+ top: -80,
+ left: -40,
+ width: 200,
+ height: 200,
+ backgroundColor: '#1b2a44',
+ opacity: 0.35,
+ borderRadius: 140,
+ },
+ glowBottom: {
+ position: 'absolute',
+ bottom: -120,
+ right: -60,
+ width: 240,
+ height: 240,
+ backgroundColor: '#123125',
+ opacity: 0.25,
+ borderRadius: 200,
+ },
+ iconButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.08)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255,255,255,0.04)',
+ },
+ headerTitle: {
+ alignItems: 'center',
+ flex: 1,
+ gap: 6,
+ },
+ badge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderRadius: 999,
+ backgroundColor: '#d6b37f',
+ },
+ badgeText: {
+ color: '#0b0f16',
+ fontSize: 12,
+ fontWeight: '700',
+ fontFamily: 'AliBold',
+ },
+ title: {
+ color: '#f6f7fb',
+ fontSize: 22,
+ fontFamily: 'AliBold',
+ },
+ subtitle: {
+ color: '#b9c2d3',
+ fontSize: 14,
+ fontFamily: 'AliRegular',
+ },
+ heroCard: {
+ borderRadius: 24,
+ padding: 18,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.06)',
+ shadowColor: '#000',
+ shadowOpacity: 0.25,
+ shadowRadius: 16,
+ gap: 14,
+ },
+ heroHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ heroLabel: {
+ color: '#f5f6fb',
+ fontSize: 16,
+ fontFamily: 'AliBold',
+ },
+ updatedAt: {
+ color: '#8b94a8',
+ fontSize: 12,
+ fontFamily: 'AliRegular',
+ },
+ heroMainRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: 16,
+ },
+ heroLeft: {
+ flex: 1,
+ minWidth: 0,
+ },
+ heroValue: {
+ color: '#36d0a5',
+ fontSize: 38,
+ lineHeight: 42,
+ fontFamily: 'AliBold',
+ letterSpacing: 0.5,
+ flexShrink: 1,
+ },
+ heroCaption: {
+ color: '#c2ccdf',
+ fontSize: 13,
+ fontFamily: 'AliRegular',
+ marginTop: 4,
+ },
+ heroProgressTrack: {
+ marginTop: 12,
+ height: 10,
+ borderRadius: 10,
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ overflow: 'hidden',
+ },
+ heroProgressFill: {
+ height: '100%',
+ borderRadius: 10,
+ backgroundColor: '#36d0a5',
+ },
+ heroChip: {
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ borderRadius: 18,
+ backgroundColor: 'rgba(214, 179, 127, 0.12)',
+ borderWidth: 1,
+ borderColor: 'rgba(214, 179, 127, 0.3)',
+ minWidth: 120,
+ alignItems: 'flex-start',
+ gap: 4,
+ },
+ heroChipLabel: {
+ color: '#d6b37f',
+ fontSize: 12,
+ fontFamily: 'AliRegular',
+ },
+ heroChipValue: {
+ color: '#f6f7fb',
+ fontSize: 20,
+ fontFamily: 'AliBold',
+ lineHeight: 24,
+ },
+ heroChipHint: {
+ color: '#b9c2d3',
+ fontSize: 12,
+ fontFamily: 'AliRegular',
+ },
+ heroStatsRow: {
+ flexDirection: 'row',
+ gap: 12,
+ justifyContent: 'space-between',
+ },
+ heroStatItem: {
+ flex: 1,
+ padding: 12,
+ borderRadius: 14,
+ backgroundColor: 'rgba(255,255,255,0.04)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.04)',
+ },
+ heroStatLabel: {
+ color: '#9dabc4',
+ fontSize: 12,
+ fontFamily: 'AliRegular',
+ },
+ heroStatValue: {
+ color: '#f6f7fb',
+ fontSize: 18,
+ marginTop: 6,
+ fontFamily: 'AliBold',
+ },
+ sectionCard: {
+ borderRadius: 20,
+ padding: 16,
+ backgroundColor: 'rgba(255,255,255,0.03)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.05)',
+ gap: 12,
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ sectionTitle: {
+ color: '#f5f6fb',
+ fontSize: 16,
+ fontFamily: 'AliBold',
+ },
+ pillChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ backgroundColor: '#d6b37f',
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderRadius: 999,
+ },
+ pillChipText: {
+ color: '#0b0f16',
+ fontSize: 12,
+ fontFamily: 'AliBold',
+ },
+ insightText: {
+ color: '#d9e2f2',
+ fontSize: 15,
+ lineHeight: 22,
+ fontFamily: 'AliRegular',
+ },
+ planList: {
+ gap: 12,
+ },
+ planCard: {
+ borderRadius: 16,
+ padding: 14,
+ backgroundColor: 'rgba(255,255,255,0.04)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.06)',
+ gap: 10,
+ },
+ planHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ planName: {
+ color: '#f6f7fb',
+ fontSize: 16,
+ fontFamily: 'AliBold',
+ },
+ planMeta: {
+ color: '#9dabc4',
+ fontSize: 12,
+ fontFamily: 'AliRegular',
+ marginTop: 2,
+ },
+ planChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderRadius: 999,
+ backgroundColor: 'rgba(214, 179, 127, 0.15)',
+ borderWidth: 1,
+ borderColor: 'rgba(214, 179, 127, 0.35)',
+ },
+ planChipText: {
+ color: '#d6b37f',
+ fontSize: 12,
+ fontFamily: 'AliBold',
+ },
+ progressRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ progressTrack: {
+ flex: 1,
+ height: 10,
+ borderRadius: 10,
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ backgroundColor: '#36d0a5',
+ borderRadius: 10,
+ },
+ progressValue: {
+ color: '#f6f7fb',
+ fontSize: 12,
+ fontFamily: 'AliBold',
+ },
+ planFooter: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ planStat: {
+ color: '#c7d1e4',
+ fontSize: 13,
+ fontFamily: 'AliRegular',
+ },
+ planDate: {
+ color: '#7f8aa4',
+ fontSize: 12,
+ fontFamily: 'AliRegular',
+ },
+ errorCard: {
+ padding: 16,
+ borderRadius: 16,
+ backgroundColor: 'rgba(255, 86, 86, 0.08)',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 86, 86, 0.3)',
+ alignItems: 'center',
+ gap: 12,
+ },
+ errorTitle: {
+ color: '#ff9c9c',
+ fontSize: 14,
+ textAlign: 'center',
+ fontFamily: 'AliBold',
+ },
+ retryButton: {
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ borderRadius: 999,
+ backgroundColor: '#ff9c9c',
+ },
+ retryText: {
+ color: '#0b0f16',
+ fontSize: 13,
+ fontFamily: 'AliBold',
+ },
+ loadingRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ paddingVertical: 12,
+ },
+ loadingText: {
+ color: '#c7d1e4',
+ fontSize: 13,
+ fontFamily: 'AliRegular',
+ },
+ emptyState: {
+ paddingVertical: 12,
+ gap: 6,
+ },
+ emptyTitle: {
+ color: '#f6f7fb',
+ fontSize: 15,
+ fontFamily: 'AliBold',
+ },
+ emptySubtitle: {
+ color: '#9dabc4',
+ fontSize: 13,
+ fontFamily: 'AliRegular',
+ lineHeight: 20,
+ },
+ infoOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ },
+ infoModal: {
+ width: '100%',
+ maxWidth: 400,
+ borderRadius: 24,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.1)',
+ },
+ infoGradient: {
+ padding: 24,
+ gap: 20,
+ },
+ infoHeader: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 4,
+ },
+ infoBadge: {
+ color: '#d6b37f',
+ fontSize: 24,
+ lineHeight: 28,
+ fontFamily: 'AliBold',
+ marginBottom: 10,
+ letterSpacing: 0.5,
+ },
+ infoTitle: {
+ color: '#f6f7fb',
+ fontSize: 16,
+ fontFamily: 'AliBold',
+ textAlign: 'center',
+ },
+ infoClose: {
+ position: 'absolute',
+ right: -4,
+ top: -4,
+ padding: 8,
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: 18,
+ backgroundColor: 'rgba(255,255,255,0.05)',
+ },
+ infoContent: {
+ gap: 14,
+ },
+ infoText: {
+ color: '#d9e2f2',
+ fontSize: 14,
+ lineHeight: 18,
+ fontFamily: 'AliRegular',
+ },
+ infoButtonContainer: {
+ marginTop: 12,
+ alignItems: 'center',
+ },
+ infoButtonWrapper: {
+ // minWidth: 120,
+ // maxWidth: 180,
+ },
+ infoButton: {
+ borderRadius: 12,
+ paddingVertical: 10,
+ paddingHorizontal: 28,
+ alignItems: 'center',
+ overflow: 'hidden',
+ },
+ infoButtonGlass: {
+ paddingVertical: 10,
+ paddingHorizontal: 28,
+ alignItems: 'center',
+ },
+ infoButtonText: {
+ color: '#0b0f16',
+ fontSize: 15,
+ fontFamily: 'AliBold',
+ letterSpacing: 0.2,
+ },
+ infoIconButton: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(139, 148, 168, 0.1)',
+ },
+});
diff --git a/assets/images/medicine/medicine-ai-summary.png b/assets/images/medicine/medicine-ai-summary.png
new file mode 100644
index 0000000..c3851ef
Binary files /dev/null and b/assets/images/medicine/medicine-ai-summary.png differ
diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx
index 5edc090..ca187d8 100644
--- a/components/StepsCard.tsx
+++ b/components/StepsCard.tsx
@@ -1,14 +1,17 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
- Animated,
- InteractionManager,
- StyleSheet,
- Text,
- TouchableOpacity,
- View,
- ViewStyle
+ Animated,
+ InteractionManager,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+ ViewStyle
} from 'react-native';
+import { useAppDispatch, useAppSelector } from '@/hooks/redux';
+import { ChallengeType } from '@/services/challengesApi';
+import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
@@ -20,8 +23,8 @@ import { AnimatedNumber } from './AnimatedNumber';
// import Svg, { Rect } from 'react-native-svg';
interface StepsCardProps {
- curDate: Date
- stepGoal: number;
+ curDate: Date;
+ stepGoal?: number;
style?: ViewStyle;
}
@@ -31,9 +34,20 @@ const StepsCard: React.FC = ({
}) => {
const { t } = useTranslation();
const router = useRouter();
+ const dispatch = useAppDispatch();
+ const challenges = useAppSelector(selectChallengeList);
- const [stepCount, setStepCount] = useState(0)
- const [hourlySteps, setHourSteps] = useState([])
+ const [stepCount, setStepCount] = useState(0);
+ const [hourlySteps, setHourSteps] = useState([]);
+
+ // 过滤出已参加的步数挑战
+ const joinedStepsChallenges = useMemo(
+ () => challenges.filter((challenge) => challenge.type === ChallengeType.STEP && challenge.isJoined && challenge.status === 'ongoing'),
+ [challenges]
+ );
+
+ // 跟踪上次上报的记录,避免重复上报
+ const lastReportedRef = useRef<{ date: string; value: number } | null>(null);
const getStepData = useCallback(async (date: Date) => {
@@ -59,6 +73,42 @@ const StepsCard: React.FC = ({
}
}, [curDate]);
+ // 步数挑战进度上报逻辑
+ useEffect(() => {
+ if (!curDate || !stepCount || !joinedStepsChallenges.length) {
+ return;
+ }
+
+ // 如果当前日期不是今天,不上报
+ if (!dayjs(curDate).isSame(dayjs(), 'day')) {
+ return;
+ }
+
+ const dateKey = dayjs(curDate).format('YYYY-MM-DD');
+ const lastReport = lastReportedRef.current;
+
+ if (lastReport && lastReport.date === dateKey && lastReport.value === stepCount) {
+ return;
+ }
+
+ const reportProgress = async () => {
+ const stepsChallenge = joinedStepsChallenges.find((c) => c.type === ChallengeType.STEP);
+ if (!stepsChallenge) {
+ return;
+ }
+
+ try {
+ await dispatch(reportChallengeProgress({ id: stepsChallenge.id, value: stepCount })).unwrap();
+ } catch (error) {
+ logger.warn('StepsCard: Challenge progress report failed', { error, challengeId: stepsChallenge.id });
+ }
+
+ lastReportedRef.current = { date: dateKey, value: stepCount };
+ };
+
+ reportProgress();
+ }, [dispatch, joinedStepsChallenges, curDate, stepCount]);
+
// 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef