feat(review): 集成iOS应用内评分功能

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

评分请求策略:
- 仅iOS 14.0+支持
- 自动控制请求频率,避免过度打扰用户
- 延迟1秒执行,不阻塞主业务流程
- 所有评分请求均做错误处理,确保不影响核心功能
This commit is contained in:
richarjiang
2025-11-24 10:06:18 +08:00
parent 8cbf6be50a
commit c1c9f22111
15 changed files with 823 additions and 335 deletions

View File

@@ -1,6 +1,8 @@
import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { useNotifications } from '@/hooks/useNotifications';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
getMedicationReminderEnabled,
getMoodReminderEnabled,
@@ -12,18 +14,15 @@ import {
setNutritionReminderEnabled
} from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useState } from 'react';
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, View } from 'react-native';
export default function NotificationSettingsScreen() {
const insets = useSafeAreaInsets();
const safeAreaTop = useSafeAreaTop(60);
const { t } = useI18n();
const { requestPermission, sendNotification } = useNotifications();
const isLgAvailable = isLiquidGlassAvailable();
// 通知设置状态
const [notificationEnabled, setNotificationEnabledState] = useState(false);
@@ -174,57 +173,41 @@ export default function NotificationSettingsScreen() {
}
};
// 返回按钮
const BackButton = () => (
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
{isLgAvailable ? (
<GlassView
style={styles.glassButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="chevron-back" size={24} color="#333" />
</GlassView>
) : (
<View style={[styles.glassButton, styles.fallbackButton]}>
<Ionicons name="chevron-back" size={24} color="#333" />
// 渲染设置项
const renderSettingItem = (
icon: keyof typeof Ionicons.glyphMap,
title: string,
description: string,
value: boolean,
onValueChange: (value: boolean) => void,
disabled: boolean = false,
showSeparator: boolean = true
) => (
<View>
<View style={styles.settingItem}>
<View style={styles.itemInfo}>
<View style={[styles.iconContainer, disabled && styles.iconContainerDisabled]}>
<Ionicons name={icon} size={24} color={disabled ? '#C7C7CC' : '#9370DB'} />
</View>
<View style={styles.textContainer}>
<Text style={[styles.itemTitle, disabled && styles.itemTitleDisabled]}>{title}</Text>
<Text style={styles.itemDescription} numberOfLines={2}>{description}</Text>
</View>
</View>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
{showSeparator && (
<View style={styles.separatorContainer}>
<View style={styles.separator} />
</View>
)}
</TouchableOpacity>
);
// 开关项组件
const SwitchItem = ({
title,
description,
value,
onValueChange,
disabled = false
}: {
title: string;
description: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
}) => (
<View style={styles.switchItem}>
<View style={styles.switchItemLeft}>
<Text style={styles.switchItemTitle}>{title}</Text>
<Text style={styles.switchItemDescription}>{description}</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
);
@@ -233,10 +216,10 @@ export default function NotificationSettingsScreen() {
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
@@ -249,97 +232,82 @@ export default function NotificationSettingsScreen() {
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar
title={t('notificationSettings.title')}
onBack={() => router.back()}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingTop: insets.top + 20,
paddingBottom: insets.bottom + 20,
paddingHorizontal: 16,
}}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
>
{/* 头部 */}
<View style={styles.header}>
<BackButton />
<ThemedText style={styles.title}>{t('notificationSettings.title')}</ThemedText>
</View>
{/* 通知设置部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.notifications')}</Text>
<View style={styles.card}>
<SwitchItem
title={t('notificationSettings.items.pushNotifications.title')}
description={t('notificationSettings.items.pushNotifications.description')}
value={notificationEnabled}
onValueChange={handleNotificationToggle}
/>
{/* 顶部说明卡片 */}
<View style={styles.headerSection}>
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.descriptionCard}>
<View style={styles.hintRow}>
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
<Text style={styles.descriptionText}>
{t('notificationSettings.description.text')}
</Text>
</View>
</View>
</View>
{/* 药品提醒部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.medicationReminder')}</Text>
<View style={styles.card}>
<SwitchItem
title={t('notificationSettings.items.medicationReminder.title')}
description={t('notificationSettings.items.medicationReminder.description')}
value={medicationReminderEnabled}
onValueChange={handleMedicationReminderToggle}
disabled={!notificationEnabled}
/>
</View>
{/* 设置项列表 */}
<View style={styles.sectionContainer}>
{renderSettingItem(
'notifications-outline',
t('notificationSettings.items.pushNotifications.title'),
t('notificationSettings.items.pushNotifications.description'),
notificationEnabled,
handleNotificationToggle,
false,
true
)}
{renderSettingItem(
'medkit-outline',
t('notificationSettings.items.medicationReminder.title'),
t('notificationSettings.items.medicationReminder.description'),
medicationReminderEnabled,
handleMedicationReminderToggle,
!notificationEnabled,
true
)}
{renderSettingItem(
'restaurant-outline',
t('notificationSettings.items.nutritionReminder.title'),
t('notificationSettings.items.nutritionReminder.description'),
nutritionReminderEnabled,
handleNutritionReminderToggle,
!notificationEnabled,
true
)}
{renderSettingItem(
'happy-outline',
t('notificationSettings.items.moodReminder.title'),
t('notificationSettings.items.moodReminder.description'),
moodReminderEnabled,
handleMoodReminderToggle,
!notificationEnabled,
false
)}
</View>
{/* 营养提醒部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.nutritionReminder')}</Text>
<View style={styles.card}>
<SwitchItem
title={t('notificationSettings.items.nutritionReminder.title')}
description={t('notificationSettings.items.nutritionReminder.description')}
value={nutritionReminderEnabled}
onValueChange={handleNutritionReminderToggle}
disabled={!notificationEnabled}
/>
</View>
</View>
{/* 心情提醒部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.moodReminder')}</Text>
<View style={styles.card}>
<SwitchItem
title={t('notificationSettings.items.moodReminder.title')}
description={t('notificationSettings.items.moodReminder.description')}
value={moodReminderEnabled}
onValueChange={handleMoodReminderToggle}
disabled={!notificationEnabled}
/>
</View>
</View>
{/* 说明部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.card}>
<Text style={styles.description}>
{t('notificationSettings.description.text')}
</Text>
</View>
</View>
</ScrollView>
</View>
);
@@ -348,37 +316,22 @@ export default function NotificationSettingsScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
height: '60%',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
@@ -388,82 +341,95 @@ const styles = StyleSheet.create({
fontSize: 16,
color: '#666',
},
header: {
headerSection: {
marginBottom: 20,
},
subtitle: {
fontSize: 14,
color: '#6C757D',
marginBottom: 12,
marginLeft: 4,
},
descriptionCard: {
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: 'rgba(147, 112, 219, 0.1)',
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 24,
gap: 8,
},
backButton: {
marginRight: 16,
descriptionText: {
flex: 1,
fontSize: 13,
color: '#2C3E50',
lineHeight: 18,
},
glassButton: {
sectionContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
marginBottom: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
paddingVertical: 16,
},
itemInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#2C3E50',
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: '#2C3E50',
marginBottom: 12,
paddingHorizontal: 4,
},
card: {
backgroundColor: '#FFFFFF',
backgroundColor: 'rgba(147, 112, 219, 0.05)',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
switchItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
iconContainerDisabled: {
backgroundColor: '#F5F5F5',
},
switchItemLeft: {
textContainer: {
flex: 1,
marginRight: 16,
marginRight: 8,
},
switchItemTitle: {
itemTitle: {
fontSize: 16,
fontWeight: '500',
color: '#2C3E50',
marginBottom: 4,
},
switchItemDescription: {
fontSize: 14,
itemTitleDisabled: {
color: '#999',
},
itemDescription: {
fontSize: 12,
color: '#6C757D',
lineHeight: 20,
lineHeight: 16,
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},
description: {
fontSize: 14,
color: '#6C757D',
lineHeight: 22,
paddingVertical: 16,
paddingHorizontal: 16,
separatorContainer: {
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
paddingRight: 16,
},
separator: {
height: 1,
backgroundColor: '#F0F0F0',
},
});