Files
digital-pilates/app/(tabs)/challenges.tsx
richarjiang 8f847465ef feat(challenges): 新增挑战鼓励提醒后台任务与通知支持
- 在 backgroundTaskManager 中增加 executeChallengeReminderTask,每日检查已加入且未打卡的挑战并发送鼓励通知
- 扩展 ChallengeNotificationHelpers 提供 sendEncouragementNotification 方法
- 新增 NotificationTypes.CHALLENGE_ENCOURAGEMENT 及对应点击跳转处理
- challengesApi 补充 checkedInToday 字段用于判断今日是否已打卡
- 临时注释掉挑战列表与详情页头部的礼物/分享按钮,避免干扰主流程
2025-09-29 17:24:07 +08:00

371 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchChallenges,
selectChallengeCards,
selectChallengesListError,
selectChallengesListStatus,
type ChallengeCardViewModel,
} from '@/store/challengesSlice';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo } from 'react';
import {
ActivityIndicator,
ScrollView,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
const AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132;
const CARD_IMAGE_HEIGHT = 96;
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
upcoming: '即将开始',
ongoing: '进行中',
expired: '已结束',
};
export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError);
const ongoingChallenge = useMemo(
() =>
challenges.find(
(challenge) => challenge.status === 'ongoing' && challenge.isJoined && challenge.progress
),
[challenges]
);
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
useEffect(() => {
if (listStatus === 'idle') {
dispatch(fetchChallenges());
}
}, [dispatch, listStatus]);
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const renderChallenges = () => {
if (listStatus === 'loading' && challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
</View>
);
}
if (listStatus === 'failed' && challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
{listError ?? '加载挑战失败,请稍后重试'}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallenges())}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View>
);
}
if (challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
</View>
);
}
return challenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
surfaceColor={colorTokens.surface}
textColor={colorTokens.text}
mutedColor={colorTokens.textSecondary}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
));
};
return (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
bounces
>
<View style={styles.headerRow}>
<View>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}></Text>
</View>
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
<LinearGradient
colors={[colorTokens.primary, colorTokens.accentPurple]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.giftButton}
>
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
</LinearGradient>
</TouchableOpacity> */}
</View>
{ongoingChallenge ? (
<TouchableOpacity
activeOpacity={0.92}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: ongoingChallenge.id } })
}
>
<ChallengeProgressCard
title={ongoingChallenge.title}
endAt={ongoingChallenge.endAt}
progress={ongoingChallenge.progress}
style={styles.progressCardWrapper}
backgroundColors={[colorTokens.card, colorTokens.card]}
titleColor={colorTokens.text}
subtitleColor={colorTokens.textSecondary}
metaColor={colorTokens.primary}
metaSuffixColor={colorTokens.textSecondary}
accentColor={colorTokens.primary}
trackColor={progressTrackColor}
inactiveColor={progressInactiveColor}
/>
</TouchableOpacity>
) : null}
<View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
type ChallengeCardProps = {
challenge: ChallengeCardViewModel;
surfaceColor: string;
textColor: string;
mutedColor: string;
onPress: () => void;
};
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
return (
<TouchableOpacity
activeOpacity={0.92}
onPress={onPress}
style={[
styles.card,
{
backgroundColor: surfaceColor,
shadowColor: 'rgba(15, 23, 42, 0.18)',
},
]}
>
<Image
source={{ uri: challenge.image }}
style={styles.cardImage}
cachePolicy={'memory-disk'}
/>
<View style={styles.cardContent}>
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}>
{challenge.title}
</Text>
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
<Text style={[styles.cardParticipants, { color: mutedColor }]}>
{challenge.participantsLabel}
{' · '}
{statusLabel}
{challenge.isJoined ? ' · 已加入' : ''}
</Text>
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null}
</View>
</TouchableOpacity>
);
}
type AvatarStackProps = {
avatars: string[];
borderColor: string;
};
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
return (
<View style={styles.avatarRow}>
{avatars
.filter(Boolean)
.map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[
styles.avatar,
{ borderColor },
index === 0 ? null : styles.avatarOffset,
]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
safeArea: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 32,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
marginBottom: 26,
},
title: {
fontSize: 32,
fontWeight: '700',
letterSpacing: 1,
},
subtitle: {
marginTop: 6,
fontSize: 14,
fontWeight: '500',
opacity: 0.8,
},
giftShadow: {
shadowColor: 'rgba(94, 62, 199, 0.45)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 12,
elevation: 8,
borderRadius: 26,
},
giftButton: {
width: 32,
height: 32,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
},
cardsContainer: {
gap: 18,
},
progressCardWrapper: {
marginBottom: 24,
},
stateContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
paddingHorizontal: 20,
},
stateText: {
marginTop: 12,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
retryButton: {
marginTop: 16,
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 18,
borderWidth: StyleSheet.hairlineWidth,
},
retryText: {
fontSize: 13,
fontWeight: '600',
},
card: {
flexDirection: 'row',
borderRadius: 28,
padding: 18,
alignItems: 'center',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.18,
shadowRadius: 24,
elevation: 6,
},
cardImage: {
width: CARD_IMAGE_WIDTH,
height: CARD_IMAGE_HEIGHT,
borderRadius: 22,
},
cardContent: {
flex: 1,
marginLeft: 16,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
cardDate: {
fontSize: 13,
fontWeight: '500',
marginBottom: 4,
},
cardParticipants: {
fontSize: 13,
fontWeight: '500',
},
cardProgress: {
marginTop: 8,
fontSize: 13,
fontWeight: '600',
},
avatarRow: {
flexDirection: 'row',
marginTop: 16,
alignItems: 'center',
},
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
borderWidth: 2,
},
avatarOffset: {
marginLeft: -12,
},
});