feat(challenges): 新增挑战鼓励提醒后台任务与通知支持
- 在 backgroundTaskManager 中增加 executeChallengeReminderTask,每日检查已加入且未打卡的挑战并发送鼓励通知 - 扩展 ChallengeNotificationHelpers 提供 sendEncouragementNotification 方法 - 新增 NotificationTypes.CHALLENGE_ENCOURAGEMENT 及对应点击跳转处理 - challengesApi 补充 checkedInToday 字段用于判断今日是否已打卡 - 临时注释掉挑战列表与详情页头部的礼物/分享按钮,避免干扰主流程
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
@@ -127,7 +126,7 @@ export default function ChallengesScreen() {
|
|||||||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
@@ -136,7 +135,7 @@ export default function ChallengesScreen() {
|
|||||||
>
|
>
|
||||||
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
|
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity> */}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{ongoingChallenge ? (
|
{ongoingChallenge ? (
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
const progress = challenge?.progress;
|
const progress = challenge?.progress;
|
||||||
|
|
||||||
const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]);
|
const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]);
|
||||||
|
|
||||||
const participantAvatars = useMemo(
|
const participantAvatars = useMemo(
|
||||||
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||||
[rankingData],
|
[rankingData],
|
||||||
@@ -296,11 +297,11 @@ export default function ChallengeDetailScreen() {
|
|||||||
tone="light"
|
tone="light"
|
||||||
transparent
|
transparent
|
||||||
withSafeTop={false}
|
withSafeTop={false}
|
||||||
right={
|
// right={
|
||||||
<TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
|
// <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
|
||||||
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
// <Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||||
</TouchableOpacity>
|
// </TouchableOpacity>
|
||||||
}
|
// }
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { store } from '@/store';
|
import { store } from '@/store';
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
import { log } from '@/utils/logger';
|
import { log } from '@/utils/logger';
|
||||||
import { StandReminderHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
import { listChallenges } from '@/services/challengesApi';
|
||||||
|
import { ChallengeNotificationHelpers, StandReminderHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import * as BackgroundTask from 'expo-background-task';
|
import * as BackgroundTask from 'expo-background-task';
|
||||||
import * as TaskManager from 'expo-task-manager';
|
import * as TaskManager from 'expo-task-manager';
|
||||||
import { TaskManagerTaskBody } from 'expo-task-manager';
|
import { TaskManagerTaskBody } from 'expo-task-manager';
|
||||||
@@ -99,6 +100,55 @@ async function executeStandReminderTask(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeChallengeReminderTask(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('执行挑战鼓励提醒后台任务...');
|
||||||
|
|
||||||
|
const state = store.getState();
|
||||||
|
const normalizedUserName = state.user.profile?.name?.trim();
|
||||||
|
const userName = normalizedUserName && normalizedUserName.length > 0 ? normalizedUserName : '朋友';
|
||||||
|
|
||||||
|
const challenges = await listChallenges();
|
||||||
|
const joinedChallenges = challenges.filter((challenge) => challenge.isJoined && challenge.progress);
|
||||||
|
|
||||||
|
if (!joinedChallenges.length) {
|
||||||
|
console.log('没有加入的挑战或挑战没有进度,跳过挑战提醒');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayKey = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
for (const challenge of joinedChallenges) {
|
||||||
|
const progress = challenge.progress;
|
||||||
|
if (!progress || progress.checkedInToday) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = `@challenge_encouragement_sent:${challenge.id}`;
|
||||||
|
const lastSent = await AsyncStorage.getItem(storageKey);
|
||||||
|
if (lastSent === todayKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ChallengeNotificationHelpers.sendEncouragementNotification({
|
||||||
|
userName,
|
||||||
|
challengeTitle: challenge.title,
|
||||||
|
challengeId: challenge.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(storageKey, todayKey);
|
||||||
|
} catch (notificationError) {
|
||||||
|
console.error('发送挑战鼓励通知失败:', notificationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('挑战鼓励提醒后台任务完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('执行挑战鼓励提醒后台任务失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送测试通知以验证后台任务执行
|
// 发送测试通知以验证后台任务执行
|
||||||
async function sendTestNotification(): Promise<void> {
|
async function sendTestNotification(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -149,6 +199,8 @@ async function executeBackgroundTasks(): Promise<void> {
|
|||||||
// 执行站立提醒检查任务
|
// 执行站立提醒检查任务
|
||||||
// await executeStandReminderTask();
|
// await executeStandReminderTask();
|
||||||
|
|
||||||
|
await executeChallengeReminderTask();
|
||||||
|
|
||||||
console.log('后台任务执行完成');
|
console.log('后台任务执行完成');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('执行后台任务失败:', error);
|
console.error('执行后台任务失败:', error);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ChallengeProgressDto = {
|
|||||||
completed: number;
|
completed: number;
|
||||||
target: number;
|
target: number;
|
||||||
remaining: number
|
remaining: number
|
||||||
|
checkedInToday: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RankingItemDto = {
|
export type RankingItemDto = {
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ export class NotificationService {
|
|||||||
if (data?.url) {
|
if (data?.url) {
|
||||||
router.push(data.url as any);
|
router.push(data.url as any);
|
||||||
}
|
}
|
||||||
|
} else if (data?.type === NotificationTypes.CHALLENGE_ENCOURAGEMENT) {
|
||||||
|
console.log('用户点击了挑战提醒通知', data);
|
||||||
|
const targetUrl = (data?.url as string) || '/(tabs)/challenges';
|
||||||
|
router.push(targetUrl as any);
|
||||||
} else if (data?.type === 'mood_reminder') {
|
} else if (data?.type === 'mood_reminder') {
|
||||||
// 处理心情提醒通知
|
// 处理心情提醒通知
|
||||||
console.log('用户点击了心情提醒通知', data);
|
console.log('用户点击了心情提醒通知', data);
|
||||||
@@ -506,6 +510,7 @@ export const NotificationTypes = {
|
|||||||
MOOD_REMINDER: 'mood_reminder',
|
MOOD_REMINDER: 'mood_reminder',
|
||||||
WATER_REMINDER: 'water_reminder',
|
WATER_REMINDER: 'water_reminder',
|
||||||
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
||||||
|
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 便捷方法
|
// 便捷方法
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { NotificationData, notificationService } from '../services/notifications';
|
import { NotificationData, NotificationTypes, notificationService } from '../services/notifications';
|
||||||
import { getNotificationEnabled } from './userPreferences';
|
import { getNotificationEnabled } from './userPreferences';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,6 +287,33 @@ export class GoalNotificationHelpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ChallengeNotificationHelpers {
|
||||||
|
static buildChallengesTabUrl(): string {
|
||||||
|
return '/(tabs)/challenges';
|
||||||
|
}
|
||||||
|
|
||||||
|
static async sendEncouragementNotification(params: {
|
||||||
|
userName: string;
|
||||||
|
challengeTitle: string;
|
||||||
|
challengeId: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { userName, challengeTitle, challengeId } = params;
|
||||||
|
const notification: NotificationData = {
|
||||||
|
title: '挑战提醒',
|
||||||
|
body: `${userName},今天还没有完成「${challengeTitle}」挑战,快来打卡吧!`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.CHALLENGE_ENCOURAGEMENT,
|
||||||
|
challengeId,
|
||||||
|
url: ChallengeNotificationHelpers.buildChallengesTabUrl(),
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
};
|
||||||
|
|
||||||
|
return notificationService.sendImmediateNotification(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 营养相关的通知辅助函数
|
* 营养相关的通知辅助函数
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user