feat(review): 集成iOS应用内评分功能
- 新增iOS原生模块AppStoreReviewManager,封装StoreKit评分请求 - 实现appStoreReviewService服务层,管理评分请求时间间隔(14天) - 在关键用户操作后触发评分请求:完成挑战、记录服药、记录体重、记录饮水 - 优化通知设置页面UI,改进设置项布局和视觉层次 - 调整用药卡片样式,优化状态显示和文字大小 - 新增配置检查脚本check-app-review-setup.sh - 修改喝水提醒默认状态为关闭 评分请求策略: - 仅iOS 14.0+支持 - 自动控制请求频率,避免过度打扰用户 - 延迟1秒执行,不阻塞主业务流程 - 所有评分请求均做错误处理,确保不影响核心功能
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -107,6 +108,13 @@ export default function WeightRecordsPage() {
|
||||
if (pickerType === 'current') {
|
||||
// Update current weight in profile and add weight record
|
||||
await dispatch(updateUserProfile({ weight: weight }) as any);
|
||||
|
||||
// 记录体重后尝试请求应用评分(延迟1秒,避免阻塞主流程)
|
||||
setTimeout(() => {
|
||||
appStoreReviewService.requestReview().catch((error) => {
|
||||
console.error('应用评分请求失败:', error);
|
||||
});
|
||||
}, 1000);
|
||||
} else if (pickerType === 'initial') {
|
||||
// Update initial weight in profile
|
||||
console.log('更新初始体重');
|
||||
|
||||
Reference in New Issue
Block a user