feat(challenges): 新增挑战鼓励提醒后台任务与通知支持

- 在 backgroundTaskManager 中增加 executeChallengeReminderTask,每日检查已加入且未打卡的挑战并发送鼓励通知
- 扩展 ChallengeNotificationHelpers 提供 sendEncouragementNotification 方法
- 新增 NotificationTypes.CHALLENGE_ENCOURAGEMENT 及对应点击跳转处理
- challengesApi 补充 checkedInToday 字段用于判断今日是否已打卡
- 临时注释掉挑战列表与详情页头部的礼物/分享按钮,避免干扰主流程
This commit is contained in:
richarjiang
2025-09-29 17:24:07 +08:00
parent d74bd214ed
commit 8f847465ef
6 changed files with 95 additions and 10 deletions

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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);

View File

@@ -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 = {

View File

@@ -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;
// 便捷方法 // 便捷方法

View File

@@ -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);
}
}
/** /**
* 营养相关的通知辅助函数 * 营养相关的通知辅助函数
*/ */