Files
digital-pilates/components/WaterIntakeCard.tsx
richarjiang c1c9f22111 feat(review): 集成iOS应用内评分功能
- 新增iOS原生模块AppStoreReviewManager,封装StoreKit评分请求
- 实现appStoreReviewService服务层,管理评分请求时间间隔(14天)
- 在关键用户操作后触发评分请求:完成挑战、记录服药、记录体重、记录饮水
- 优化通知设置页面UI,改进设置项布局和视觉层次
- 调整用药卡片样式,优化状态显示和文字大小
- 新增配置检查脚本check-app-review-setup.sh
- 修改喝水提醒默认状态为关闭

评分请求策略:
- 仅iOS 14.0+支持
- 自动控制请求频率,避免过度打扰用户
- 延迟1秒执行,不阻塞主业务流程
- 所有评分请求均做错误处理,确保不影响核心功能
2025-11-24 10:06:18 +08:00

374 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useWaterDataByDate } from '@/hooks/useWaterData';
import { appStoreReviewService } from '@/services/appStoreReview';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { AnimatedNumber } from './AnimatedNumber';
interface WaterIntakeCardProps {
style?: ViewStyle;
selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD
}
const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
style,
selectedDate
}) => {
const { t } = useTranslation();
const router = useRouter();
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord, getWaterRecordsByDate } = useWaterDataByDate(selectedDate);
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
// 计算当前饮水量和目标
const currentIntake = waterStats?.totalAmount || 0;
const targetIntake = dailyWaterGoal || 2000;
const animationRef = useRef<LottieView>(null);
// 为每个时间点创建独立的动画值
const animatedValues = useMemo(() =>
Array.from({ length: 24 }, () => new Animated.Value(0))
, []);
// 计算柱状图数据
const chartData = useMemo(() => {
if (!waterRecords || waterRecords.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, amount: 0, height: 0 }));
}
// 按小时分组数据
const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
amount: 0,
}));
waterRecords.forEach(record => {
// 优先使用 recordedAt如果没有则使用 createdAt
const dateTime = record.recordedAt || record.createdAt;
const hour = dayjs(dateTime).hour();
if (hour >= 0 && hour < 24) {
hourlyData[hour].amount += record.amount;
}
});
// 找到最大饮水量用于计算高度比例
const maxAmount = Math.max(...hourlyData.map(data => data.amount), 1);
const maxHeight = 20; // 柱状图最大高度
return hourlyData.map(data => ({
hour: data.hour,
amount: data.amount,
height: maxAmount > 0 ? (data.amount / maxAmount) * maxHeight : 0
}));
}, [waterRecords]);
// 判断是否是今天
const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate;
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
const loadDataOnFocus = async () => {
try {
// 重新加载快速添加饮水默认值
const amount = await getQuickWaterAmount();
setQuickWaterAmount(amount);
// 重新获取水数据以刷新显示
const targetDate = selectedDate || dayjs().format('YYYY-MM-DD');
await getWaterRecordsByDate(targetDate);
} catch (error) {
console.error('Failed to load data on focus:', error);
}
};
loadDataOnFocus();
}, [selectedDate, getWaterRecordsByDate])
);
// 触发柱体动画
useEffect(() => {
if (chartData && chartData.length > 0) {
// 重置所有动画值
animatedValues.forEach(animValue => animValue.setValue(0));
// 找出所有有饮水记录的柱体索引
const activeBarIndices = chartData
.map((data, index) => ({ data, index }))
.filter(item => item.data.amount > 0)
.map(item => item.index);
// 依次执行动画每个柱体间隔100ms
activeBarIndices.forEach((barIndex, sequenceIndex) => {
setTimeout(() => {
Animated.spring(animatedValues[barIndex], {
toValue: 1,
tension: 150,
friction: 8,
useNativeDriver: false,
}).start();
}, sequenceIndex * 100); // 每个柱体延迟100ms
});
}
}, [chartData, animatedValues]);
// 处理添加喝水 - 右上角按钮直接添加
const handleQuickAddWater = async () => {
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
animationRef.current?.play();
// 使用用户配置的快速添加饮水量
const waterAmount = quickWaterAmount;
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
const recordedAt = dayjs().toISOString()
await addWaterRecord(waterAmount, recordedAt);
// 记录饮水后尝试请求应用评分
await appStoreReviewService.requestReview();
};
// 处理卡片点击 - 跳转到饮水详情页面
const handleCardPress = async () => {
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
// 跳转到饮水详情页面,传递选中的日期参数
router.push({
pathname: '/water/detail',
params: selectedDate ? { selectedDate } : undefined
});
};
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={handleCardPress}
activeOpacity={0.8}
>
<LottieView
ref={animationRef}
autoPlay={false}
loop={false}
source={require('@/assets/lottie/Confetti.json')}
style={{
width: 150,
height: 150,
position: 'absolute',
left: '15%',
}}
/>
{/* 标题和加号按钮 */}
<View style={styles.header}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
}}>
<Image
source={require('@/assets/images/icons/IconGlass.png')}
style={styles.titleIcon}
/>
<Text style={styles.title}>{t('statistics.components.water.title')}</Text>
</View>
{isToday && (
<TouchableOpacity style={styles.addButton} onPress={handleQuickAddWater}>
<Text style={styles.addButtonText}>{t('statistics.components.water.addButton', { amount: quickWaterAmount })}</Text>
</TouchableOpacity>
)}
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{chartData.map((data, index) => {
// 判断是否有活动的小时
const isActive = data.amount > 0;
// 动画变换高度从0到目标高度
const animatedHeight = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, data.height],
});
// 动画变换透明度从0到1保持柱体在动画过程中可见
const animatedOpacity = animatedValues[index].interpolate({
inputRange: [0, 0.1, 1],
outputRange: [0, 1, 1],
});
return (
<View key={`bar-container-${index}`} style={styles.barContainer}>
{/* 背景柱体 - 始终显示,使用蓝色系的淡色 */}
<View
style={[
styles.chartBar,
{
height: 20, // 背景柱体占满整个高度
backgroundColor: '#F0F9FF', // 蓝色系淡色
}
]}
/>
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
{isActive && (
<Animated.View
style={[
styles.chartBar,
{
height: animatedHeight,
backgroundColor: '#7DD3FC', // 蓝色系
opacity: animatedOpacity,
}
]}
/>
)}
</View>
);
})}
</View>
</View>
</View>
{/* 饮水量显示 */}
<View style={styles.statsContainer}>
{currentIntake !== null ? (
<AnimatedNumber
value={currentIntake}
style={styles.currentIntake}
format={(value) => `${Math.round(value)}${t('statistics.components.water.unit')}`}
resetToken={selectedDate}
/>
) : (
<Text style={styles.currentIntake}>--</Text>
)}
<Text style={styles.targetIntake}>
/ {targetIntake}{t('statistics.components.water.unit')}
</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
backgroundColor: '#FFFFFF',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
minHeight: 22,
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600',
},
addButton: {
borderRadius: 16,
backgroundColor: '#E1E7FF',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
paddingVertical: 5,
},
addButtonText: {
fontSize: 10,
color: '#6366F1',
fontWeight: '700',
lineHeight: 10,
},
chartContainer: {
flex: 1,
justifyContent: 'center',
},
chartWrapper: {
width: '100%',
alignItems: 'center',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 20,
width: '100%',
maxWidth: 240,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
barContainer: {
width: 4,
height: 20,
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
},
chartBar: {
width: 4,
borderRadius: 1,
position: 'absolute',
bottom: 0,
},
statsContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 6,
},
currentIntake: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
},
targetIntake: {
fontSize: 12,
color: '#6B7280',
marginLeft: 4,
},
});
export default WaterIntakeCard;