diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index ede8ff1..e4f8612 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -5,6 +5,7 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useNotifications } from '@/hooks/useNotifications'; import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; +import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; @@ -42,10 +43,22 @@ export default function PersonalScreen() { useFocusEffect( React.useCallback(() => { dispatch(fetchMyProfile()); - dispatch(fetchActivityHistory()) + dispatch(fetchActivityHistory()); + // 加载用户推送偏好设置 + loadNotificationPreference(); }, [dispatch]) ); + // 加载用户推送偏好设置 + const loadNotificationPreference = async () => { + try { + const enabled = await getNotificationEnabled(); + setNotificationEnabled(enabled); + } catch (error) { + console.error('加载推送偏好设置失败:', error); + } + }; + // 数据格式化函数 const formatHeight = () => { if (userProfile.height == null) return '--'; @@ -68,22 +81,22 @@ export default function PersonalScreen() { // 显示名称 const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME; - // 监听通知权限状态变化 + // 初始化时加载推送偏好设置 useEffect(() => { - if (permissionStatus === 'granted') { - setNotificationEnabled(true); - } else { - setNotificationEnabled(false); - } - }, [permissionStatus]); + loadNotificationPreference(); + }, []); // 处理通知开关变化 const handleNotificationToggle = async (value: boolean) => { if (value) { try { + // 先检查系统权限 const status = await requestPermission(); if (status === 'granted') { + // 系统权限获取成功,保存用户偏好设置 + await saveNotificationEnabled(true); setNotificationEnabled(true); + // 发送测试通知 await sendNotification({ title: '通知已开启', @@ -92,14 +105,29 @@ export default function PersonalScreen() { priority: 'normal', }); } else { - Alert.alert('权限被拒绝', '请在系统设置中开启通知权限'); + // 系统权限被拒绝,不更新用户偏好设置 + Alert.alert( + '权限被拒绝', + '请在系统设置中开启通知权限,然后再尝试开启推送功能', + [ + { text: '取消', style: 'cancel' }, + { text: '去设置', onPress: () => Linking.openSettings() } + ] + ); } } catch (error) { + console.error('开启推送通知失败:', error); Alert.alert('错误', '请求通知权限失败'); } } else { - setNotificationEnabled(false); - Alert.alert('通知已关闭', '您将不会收到推送通知'); + try { + // 关闭推送,保存用户偏好设置 + await saveNotificationEnabled(false); + setNotificationEnabled(false); + } catch (error) { + console.error('关闭推送通知失败:', error); + Alert.alert('错误', '保存设置失败'); + } } }; diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx index 13cc461..f40096b 100644 --- a/components/StepsCard.tsx +++ b/components/StepsCard.tsx @@ -54,21 +54,29 @@ const StepsCard: React.FC = ({ // 触发柱体动画 useEffect(() => { - if (chartData && chartData.length > 0) { + // 检查是否有实际数据(不只是空数组) + const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0); + + if (hasData) { // 重置所有动画值 animatedValues.forEach(animValue => animValue.setValue(0)); - // 同时启动所有柱体的弹性动画,有步数的柱体才执行动画 - chartData.forEach((data, index) => { - if (data.steps > 0) { - Animated.spring(animatedValues[index], { - toValue: 1, - tension: 150, - friction: 8, - useNativeDriver: false, - }).start(); - } - }); + // 使用 setTimeout 确保在下一个事件循环中执行动画,保证组件已完全渲染 + const timeoutId = setTimeout(() => { + // 同时启动所有柱体的弹性动画,有步数的柱体才执行动画 + chartData.forEach((data, index) => { + if (data.steps > 0) { + Animated.spring(animatedValues[index], { + toValue: 1, + tension: 150, + friction: 8, + useNativeDriver: false, + }).start(); + } + }); + }, 50); // 添加小延迟确保渲染完成 + + return () => clearTimeout(timeoutId); } }, [chartData, animatedValues]); diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx index f839680..fa64dce 100644 --- a/components/WaterIntakeCard.tsx +++ b/components/WaterIntakeCard.tsx @@ -118,7 +118,7 @@ const WaterIntakeCard: React.FC = ({ if (process.env.EXPO_OS === 'ios') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } - + // 使用用户配置的快速添加饮水量 const waterAmount = quickWaterAmount; // 如果有选中日期,则为该日期添加记录;否则为今天添加记录 @@ -132,7 +132,7 @@ const WaterIntakeCard: React.FC = ({ if (process.env.EXPO_OS === 'ios') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } - + setIsModalVisible(true); }; diff --git a/docs/notification-user-preference-implementation.md b/docs/notification-user-preference-implementation.md new file mode 100644 index 0000000..350cae3 --- /dev/null +++ b/docs/notification-user-preference-implementation.md @@ -0,0 +1,137 @@ +# 推送通知用户偏好设置控制实现文档 + +## 概述 + +本次实现为项目的推送通知系统添加了用户偏好设置控制功能。用户现在可以通过个人页面的开关来控制是否接收推送通知,该设置会在底层推送服务中生效。 + +## 实现内容 + +### 1. 用户偏好设置扩展 (`utils/userPreferences.ts`) + +新增了以下功能: + +- **接口扩展**:在 `UserPreferences` 接口中添加了 `notificationEnabled: boolean` 字段 +- **存储键**:添加了 `NOTIFICATION_ENABLED` 存储键 +- **默认值**:推送通知默认开启 (`true`) +- **新增方法**: + - `setNotificationEnabled(enabled: boolean)`: 设置推送开关状态 + - `getNotificationEnabled()`: 获取推送开关状态 + - 更新了 `resetUserPreferences()` 以包含推送设置 + +### 2. 推送服务核心控制 (`services/notifications.ts`) + +在 `NotificationService` 类中添加了用户偏好检查机制: + +- **新增方法**:`isNotificationAllowed()` - 检查用户是否允许推送通知 +- **双重检查**: + 1. 检查用户偏好设置中的推送开关 + 2. 检查系统推送权限状态 +- **统一拦截**:在 `scheduleLocalNotification()` 方法中统一检查,确保所有推送都受控制 + +### 3. 个人页面开关同步 (`app/(tabs)/personal.tsx`) + +更新了个人页面的推送开关逻辑: + +- **状态同步**:开关状态与用户偏好设置保持同步 +- **页面加载**:页面聚焦时自动加载用户推送偏好设置 +- **开关处理**: + - 开启时:先检查系统权限,成功后保存用户偏好 + - 关闭时:直接保存用户偏好设置 +- **错误处理**:提供友好的错误提示和引导 + +## 工作流程 + +``` +用户操作推送开关 + ↓ +检查系统推送权限 (仅开启时) + ↓ +保存用户偏好设置 + ↓ +底层推送服务检查 + ↓ +发送/阻止推送通知 +``` + +## 使用方式 + +### 1. 用户操作 + +用户可以在个人页面的"通知"部分找到"消息推送"开关: +- 开启:需要系统权限,成功后会发送测试通知 +- 关闭:立即生效,不会收到任何推送通知 + +### 2. 开发者使用 + +所有现有的推送通知方法都会自动受到用户偏好设置控制: + +```typescript +// 这些方法都会自动检查用户偏好设置 +await notificationService.sendImmediateNotification(notification); +await WorkoutNotificationHelpers.sendWorkoutStartReminder(userName); +await GoalNotificationHelpers.sendGoalAchievementNotification(userName, goalName); +``` + +### 3. 测试功能 + +提供了完整的测试工具 (`utils/notificationTest.ts`): + +```typescript +import { NotificationTestHelpers } from '@/utils/notificationTest'; + +// 测试用户偏好设置控制 +await NotificationTestHelpers.testUserPreferenceControl(); + +// 测试权限状态 +await NotificationTestHelpers.testPermissionStatus(); + +// 测试不同类型的通知 +await NotificationTestHelpers.testDifferentNotificationTypes(); +``` + +## 技术特点 + +### 1. 双重保护机制 +- **用户偏好层**:用户主动控制是否接收推送 +- **系统权限层**:系统级别的推送权限控制 + +### 2. 统一拦截点 +- 所有推送通知都通过 `NotificationService.scheduleLocalNotification()` 方法 +- 在此方法中统一检查用户偏好和系统权限 +- 确保没有推送能绕过用户设置 + +### 3. 状态同步 +- 个人页面开关与用户偏好设置实时同步 +- 页面聚焦时自动刷新状态 +- 避免状态不一致问题 + +### 4. 向后兼容 +- 不影响现有推送通知代码 +- 所有现有方法自动获得用户偏好控制功能 +- 默认开启推送,保持原有用户体验 + +## 日志和调试 + +系统会在控制台输出相关日志: + +``` +用户已在偏好设置中关闭推送通知 +推送通知被用户偏好设置或系统权限阻止,跳过发送 +本地通知已安排,ID: notification-id-123 +``` + +## 注意事项 + +1. **权限优先级**:即使用户开启了偏好设置,如果系统权限被拒绝,推送仍然无法发送 +2. **测试通知**:用户开启推送时会发送一条测试通知,确认功能正常 +3. **错误处理**:所有操作都有完善的错误处理和用户提示 +4. **性能影响**:每次发送推送前会进行异步检查,但影响微乎其微 + +## 未来扩展 + +可以考虑添加更细粒度的推送控制: +- 按通知类型分别控制(运动提醒、目标通知等) +- 时间段控制(勿扰时间) +- 频率控制(每日最大推送数量) + +这些功能可以基于当前的架构轻松扩展。 \ No newline at end of file diff --git a/services/notifications.ts b/services/notifications.ts index fca9d82..f0e4f27 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -1,3 +1,4 @@ +import { getNotificationEnabled } from '@/utils/userPreferences'; import * as Notifications from 'expo-notifications'; import { router } from 'expo-router'; @@ -180,6 +181,32 @@ export class NotificationService { } } + /** + * 检查用户是否允许推送通知 + */ + private async isNotificationAllowed(): Promise { + try { + // 检查用户偏好设置中的推送开关 + const userPreferenceEnabled = await getNotificationEnabled(); + if (!userPreferenceEnabled) { + console.log('用户已在偏好设置中关闭推送通知'); + return false; + } + + // 检查系统权限 + const permissionStatus = await this.getPermissionStatus(); + if (permissionStatus !== 'granted') { + console.log('系统推送权限未授予'); + return false; + } + + return true; + } catch (error) { + console.error('检查推送权限失败:', error); + return false; + } + } + /** * 发送本地推送通知 */ @@ -188,6 +215,13 @@ export class NotificationService { trigger?: Notifications.NotificationTriggerInput ): Promise { try { + // 检查用户是否允许推送通知 + const isAllowed = await this.isNotificationAllowed(); + if (!isAllowed) { + console.log('推送通知被用户偏好设置或系统权限阻止,跳过发送'); + return 'blocked_by_user_preference'; + } + const notificationId = await Notifications.scheduleNotificationAsync({ content: { title: notification.title, diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index 89d9a29..7b1f237 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -3,16 +3,19 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; // 用户偏好设置的存储键 const PREFERENCES_KEYS = { QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount', + NOTIFICATION_ENABLED: 'user_preference_notification_enabled', } as const; // 用户偏好设置接口 export interface UserPreferences { quickWaterAmount: number; + notificationEnabled: boolean; } // 默认的用户偏好设置 const DEFAULT_PREFERENCES: UserPreferences = { - quickWaterAmount: 150, // 默认快速添加饮水量为 250ml + quickWaterAmount: 150, // 默认快速添加饮水量为 150ml + notificationEnabled: true, // 默认开启消息推送 }; /** @@ -21,9 +24,11 @@ const DEFAULT_PREFERENCES: UserPreferences = { export const getUserPreferences = async (): Promise => { try { const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT); + const notificationEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED); return { quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, + notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled, }; } catch (error) { console.error('获取用户偏好设置失败:', error); @@ -59,12 +64,39 @@ export const getQuickWaterAmount = async (): Promise => { } }; +/** + * 设置消息推送开关 + * @param enabled 是否开启消息推送 + */ +export const setNotificationEnabled = async (enabled: boolean): Promise => { + try { + await AsyncStorage.setItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED, enabled.toString()); + } catch (error) { + console.error('设置消息推送开关失败:', error); + throw error; + } +}; + +/** + * 获取消息推送开关状态 + */ +export const getNotificationEnabled = async (): Promise => { + try { + const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED); + return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled; + } catch (error) { + console.error('获取消息推送开关状态失败:', error); + return DEFAULT_PREFERENCES.notificationEnabled; + } +}; + /** * 重置所有用户偏好设置为默认值 */ export const resetUserPreferences = async (): Promise => { try { await AsyncStorage.removeItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT); + await AsyncStorage.removeItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED); } catch (error) { console.error('重置用户偏好设置失败:', error); throw error;