12 Commits

Author SHA1 Message Date
richarjiang
47c8bfc5bc feat(water): 后台任务同步HealthKit饮水记录并优化目标读取逻辑 2025-09-30 15:10:48 +08:00
richarjiang
3e6f55d804 feat(challenges): 排行榜支持单位显示与健身圆环自动上报进度
- ChallengeRankingItem 新增 unit 字段,支持按单位格式化今日进度
- FitnessRingsCard 监听圆环闭合,自动向进行中的运动挑战上报 1 次进度
- 过滤已结束挑战,确保睡眠、喝水、运动进度仅上报进行中活动
- 移除 StressMeter 调试日志与 challengesSlice 多余打印
2025-09-30 14:37:15 +08:00
richarjiang
b0602b0a99 feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互
- 重构挑战列表为横向轮播,支持多进行中的挑战
- 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard
- ChallengeProgressCard 支持小时级剩余时间显示
- 新增 ChallengeRankingItem 组件展示榜单项
- 排行榜支持分页加载、下拉刷新与错误重试
- 挑战卡片新增已结束角标与渐变遮罩
- 加入/退出挑战时展示庆祝动画与错误提示
- 统一背景渐变色与卡片阴影细节
2025-09-30 11:33:24 +08:00
richarjiang
d32a822604 feat(challenges): 支持即将开始与已结束挑战的禁用态及睡眠挑战自动进度上报 2025-09-30 10:21:50 +08:00
richarjiang
8f847465ef feat(challenges): 新增挑战鼓励提醒后台任务与通知支持
- 在 backgroundTaskManager 中增加 executeChallengeReminderTask,每日检查已加入且未打卡的挑战并发送鼓励通知
- 扩展 ChallengeNotificationHelpers 提供 sendEncouragementNotification 方法
- 新增 NotificationTypes.CHALLENGE_ENCOURAGEMENT 及对应点击跳转处理
- challengesApi 补充 checkedInToday 字段用于判断今日是否已打卡
- 临时注释掉挑战列表与详情页头部的礼物/分享按钮,避免干扰主流程
2025-09-29 17:24:07 +08:00
richarjiang
d74bd214ed feat(challenges): 登录态守卫与进度条动画优化
- 在 _layout 中仅当已登录时才拉取挑战列表,避免未授权请求
- 挑战详情页加入 ensureLoggedIn 守卫,未登录时跳转登录
- ChallengeProgressCard 新增分段进度动画,提升视觉反馈
- 升级版本号至 1.0.15
2025-09-29 15:39:52 +08:00
richarjiang
970a4b8568 feat(challenges): 新增 ChallengeProgressCard 组件并接入喝水挑战进度上报
- 抽离进度卡片为独立组件,支持主题色自定义与复用
- 挑战列表页顶部展示进行中的挑战进度
- 喝水记录自动上报至关联的水挑战
- 移除旧版 challengeSlice 与冗余进度样式
- 统一使用 value 字段上报进度,兼容多类型挑战
2025-09-29 15:14:59 +08:00
richarjiang
9c86b0e565 feat(challenges): 移除旧版挑战页面并优化详情页交互
删除废弃的 app/challenge 目录及其所有文件,统一使用新的 challenges 模块。在详情页新增退出挑战确认弹窗,优化浮动 CTA 文案与交互,调整进度卡片样式与布局。
2025-09-29 14:13:10 +08:00
richarjiang
31c4e4fafa feat(challenges): 移除进度徽章与副标题并动态计算剩余天数
- 使用 dayjs 实时计算挑战结束剩余天数,替代接口返回的固定值
- 删除 badge、subtitle 字段及相关渲染逻辑,简化 UI
- 注释掉未使用的打卡操作区块,保持界面整洁
2025-09-29 10:25:22 +08:00
richarjiang
b80af23f4f feat(challenges): 优化挑战列表与详情页交互体验
- 替换 Image 为 expo-image 并启用缓存策略
- 调整礼物按钮尺寸与图标大小
- 加入挑战失败时弹出 Toast 提示
- 统一异步流程并移除冗余状态监听
- 清理调试日志与多余空行
2025-09-29 09:59:47 +08:00
richarjiang
7259bd7a2c feat(challenges): 接入真实接口并完善挑战列表与详情状态管理
- 新增 challengesApi 服务层,支持列表/详情/加入/退出/打卡接口
- 重构 challengesSlice,使用 createAsyncThunk 管理异步状态
- 列表页支持加载、空态、错误重试及状态标签
- 详情页支持进度展示、打卡、退出及错误提示
- 统一卡片与详情数据模型,支持动态状态更新
2025-09-28 14:16:32 +08:00
richarjiang
2b86ac17a6 feat: 支持活动挑战页面 2025-09-28 08:29:10 +08:00
24 changed files with 2924 additions and 1384 deletions

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Out Live", "name": "Out Live",
"slug": "digital-pilates", "slug": "digital-pilates",
"version": "1.0.14", "version": "1.0.15",
"orientation": "portrait", "orientation": "portrait",
"scheme": "digitalpilates", "scheme": "digitalpilates",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",

View File

@@ -1,145 +1,183 @@
import { IconSymbol } from '@/components/ui/IconSymbol'; import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; 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 { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import {
import { SafeAreaView } from 'react-native-safe-area-context'; ActivityIndicator,
Animated,
export const CHALLENGES = [ FlatList,
{ ScrollView,
id: 'joyful-dog-run', StyleSheet,
title: '遛狗跑步,欢乐一路', Text,
dateRange: '9月01日 - 9月30日', TouchableOpacity,
participantsLabel: '6,364 跑者', View,
image: 'https://images.unsplash.com/photo-1525253086316-d0c936c814f8?auto=format&fit=crop&w=1200&q=80', useWindowDimensions
avatars: [ } from 'react-native';
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80', import { useSafeAreaInsets } from 'react-native-safe-area-context';
'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723795-3fbce826f51f?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1502823403499-6ccfcf4fb453?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'penguin-swim',
title: '企鹅宝宝的游泳预备班',
dateRange: '9月01日 - 9月30日',
participantsLabel: '3,334 游泳者',
image: 'https://images.unsplash.com/photo-1531297484001-80022131f5a1?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1525134479668-1bee5c7c6845?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1530268729831-4b0b9e170218?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'hydration-hippo',
title: '学河马饮,做补水人',
dateRange: '9月01日 - 9月30日',
participantsLabel: '9,009 饮水者',
image: 'https://images.unsplash.com/photo-1481931098730-318b6f776db0?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723660-4bfa6584218e?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723795-3fbfb7c6a9f1?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'autumn-cycling',
title: '炎夏渐散,踏板骑秋',
dateRange: '9月01日 - 9月30日',
participantsLabel: '4,617 骑行者',
image: 'https://images.unsplash.com/photo-1509395176047-4a66953fd231?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'falcon-core',
title: '燃卡加练甄秋腰',
dateRange: '9月01日 - 9月30日',
participantsLabel: '11,995 健身爱好者',
image: 'https://images.unsplash.com/photo-1494871262121-6adf66e90adf?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
],
},
] as const;
export type Challenge = (typeof CHALLENGES)[number];
const AVATAR_SIZE = 36; const AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132; const CARD_IMAGE_WIDTH = 132;
const CARD_IMAGE_HEIGHT = 96; const CARD_IMAGE_HEIGHT = 96;
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
upcoming: '即将开始',
ongoing: '进行中',
expired: '已结束',
};
const CAROUSEL_ITEM_SPACING = 16;
const MIN_CAROUSEL_CARD_WIDTH = 280;
const DOT_BASE_SIZE = 6;
export default function ChallengesScreen() { export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const insets = useSafeAreaInsets();
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError);
const ongoingChallenges = useMemo(() => {
const now = dayjs();
return challenges.filter((challenge) => {
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
return false;
}
const gradientColors = if (challenge.endAt) {
const endDate = dayjs(challenge.endAt);
if (endDate.isValid() && endDate.isBefore(now)) {
return false;
}
}
return true;
});
}, [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' theme === 'dark'
? ['#1f2230', '#10131e'] ? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]; : [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 ( return (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}> <View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} /> <LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<SafeAreaView style={styles.safeArea} edges={['top']}> <ScrollView
<ScrollView contentContainerStyle={[styles.scrollContent, {
contentContainerStyle={styles.scrollContent} paddingTop: insets.top,
showsVerticalScrollIndicator={false} }]}
bounces showsVerticalScrollIndicator={false}
> bounces
<View style={styles.headerRow}> >
<View> <View style={styles.headerRow}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text> <View>
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.title, { color: colorTokens.text }]}></Text>
</View> <Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}></Text>
<TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}> </View>
{/* <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 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.giftButton} style={styles.giftButton}
> >
<IconSymbol name="gift.fill" size={22} color={colorTokens.onPrimary} /> <IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity> */}
</View> </View>
<View style={styles.cardsContainer}> {ongoingChallenges.length ? (
{CHALLENGES.map((challenge) => ( <OngoingChallengesCarousel
<ChallengeCard challenges={ongoingChallenges}
key={challenge.id} colorTokens={colorTokens}
challenge={challenge} trackColor={progressTrackColor}
surfaceColor={colorTokens.surface} inactiveColor={progressInactiveColor}
textColor={colorTokens.text} onPress={(challenge) =>
mutedColor={colorTokens.textSecondary} router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
onPress={() => }
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) />
} ) : null}
/>
))} <View style={styles.cardsContainer}>{renderChallenges()}</View>
</View> </ScrollView>
</ScrollView>
</SafeAreaView>
</View> </View>
); );
} }
type ChallengeCardProps = { type ChallengeCardProps = {
challenge: Challenge; challenge: ChallengeCardViewModel;
surfaceColor: string; surfaceColor: string;
textColor: string; textColor: string;
mutedColor: string; mutedColor: string;
@@ -147,6 +185,8 @@ type ChallengeCardProps = {
}; };
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) { function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
return ( return (
<TouchableOpacity <TouchableOpacity
activeOpacity={0.92} activeOpacity={0.92}
@@ -159,21 +199,207 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
}, },
]} ]}
> >
<Image <View style={styles.cardInner}>
source={{ uri: challenge.image }} <View style={styles.cardMedia}>
style={styles.cardImage} <Image
resizeMode="cover" source={{ uri: challenge.image }}
style={styles.cardImage}
cachePolicy={'memory-disk'}
/>
<>
<LinearGradient
pointerEvents="none"
colors={['rgba(17, 21, 32, 0.05)', 'rgba(13, 17, 28, 0.4)']}
style={styles.cardImageOverlay}
/>
<View style={styles.expiredBadge}>
<Text style={styles.expiredBadgeText}>{statusLabel}</Text>
</View>
</>
</View>
<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}
{challenge.isJoined ? ' · 已加入' : ''}
</Text>
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null}
</View>
</View>
</TouchableOpacity>
);
}
type ThemeColorTokens = (typeof Colors)['light'] | (typeof Colors)['dark'];
type OngoingChallengesCarouselProps = {
challenges: ChallengeCardViewModel[];
colorTokens: ThemeColorTokens;
trackColor: string;
inactiveColor: string;
onPress: (challenge: ChallengeCardViewModel) => void;
};
function OngoingChallengesCarousel({
challenges,
colorTokens,
trackColor,
inactiveColor,
onPress,
}: OngoingChallengesCarouselProps) {
const { width } = useWindowDimensions();
const cardWidth = Math.max(width - 40, MIN_CAROUSEL_CARD_WIDTH);
const snapInterval = cardWidth + CAROUSEL_ITEM_SPACING;
const scrollX = useRef(new Animated.Value(0)).current;
const listRef = useRef<FlatList<ChallengeCardViewModel> | null>(null);
useEffect(() => {
scrollX.setValue(0);
listRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [scrollX, challenges.length]);
const onScroll = useMemo(
() =>
Animated.event(
[
{
nativeEvent: {
contentOffset: { x: scrollX },
},
},
],
{ useNativeDriver: true }
),
[scrollX]
);
const renderItem = useCallback(
({ item, index }: { item: ChallengeCardViewModel; index: number }) => {
const inputRange = [
(index - 1) * snapInterval,
index * snapInterval,
(index + 1) * snapInterval,
];
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.94, 1, 0.94],
extrapolate: 'clamp',
});
const translateY = scrollX.interpolate({
inputRange,
outputRange: [10, 0, 10],
extrapolate: 'clamp',
});
return (
<Animated.View
style={[
styles.carouselCard,
{
width: cardWidth,
transform: [{ scale }, { translateY }],
},
]}
>
<TouchableOpacity
activeOpacity={0.92}
style={styles.carouselTouchable}
onPress={() => onPress(item)}
>
<ChallengeProgressCard
title={item.title}
endAt={item.endAt}
progress={item.progress}
style={styles.carouselProgressCard}
backgroundColors={[colorTokens.card, colorTokens.card]}
titleColor={colorTokens.text}
subtitleColor={colorTokens.textSecondary}
metaColor={colorTokens.primary}
metaSuffixColor={colorTokens.textSecondary}
accentColor={colorTokens.primary}
trackColor={trackColor}
inactiveColor={inactiveColor}
/>
</TouchableOpacity>
</Animated.View>
);
},
[cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor]
);
return (
<View style={styles.carouselContainer}>
<Animated.FlatList
ref={listRef}
data={challenges}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
bounces
decelerationRate="fast"
snapToAlignment="start"
snapToInterval={snapInterval}
ItemSeparatorComponent={() => <View style={{ width: CAROUSEL_ITEM_SPACING }} />}
onScroll={onScroll}
scrollEventThrottle={16}
overScrollMode="never"
renderItem={renderItem}
/> />
<View style={styles.cardContent}> {challenges.length > 1 ? (
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}> <View style={styles.carouselIndicators}>
{challenge.title} {challenges.map((challenge, index) => {
</Text> const inputRange = [
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text> (index - 1) * snapInterval,
<Text style={[styles.cardParticipants, { color: mutedColor }]}>{challenge.participantsLabel}</Text> index * snapInterval,
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} /> (index + 1) * snapInterval,
</View> ];
</TouchableOpacity> const scaleX = scrollX.interpolate({
inputRange,
outputRange: [1, 2.6, 1],
extrapolate: 'clamp',
});
const dotOpacity = scrollX.interpolate({
inputRange,
outputRange: [0.35, 1, 0.35],
extrapolate: 'clamp',
});
return (
<Animated.View
key={challenge.id}
style={[
styles.carouselDot,
{
opacity: dotOpacity,
backgroundColor: colorTokens.primary,
transform: [{ scaleX }],
},
]}
/>
);
})}
</View>
) : null}
</View>
); );
} }
@@ -185,17 +411,19 @@ type AvatarStackProps = {
function AvatarStack({ avatars, borderColor }: AvatarStackProps) { function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
return ( return (
<View style={styles.avatarRow}> <View style={styles.avatarRow}>
{avatars.map((avatar, index) => ( {avatars
<Image .filter(Boolean)
key={`${avatar}-${index}`} .map((avatar, index) => (
source={{ uri: avatar }} <Image
style={[ key={`${avatar}-${index}`}
styles.avatar, source={{ uri: avatar }}
{ borderColor }, style={[
index === 0 ? null : styles.avatarOffset, styles.avatar,
]} { borderColor },
/> index === 0 ? null : styles.avatarOffset,
))} ]}
/>
))}
</View> </View>
); );
} }
@@ -209,7 +437,7 @@ const styles = StyleSheet.create({
}, },
scrollContent: { scrollContent: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 32, paddingBottom: 120,
}, },
headerRow: { headerRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -238,8 +466,8 @@ const styles = StyleSheet.create({
borderRadius: 26, borderRadius: 26,
}, },
giftButton: { giftButton: {
width: 52, width: 32,
height: 52, height: 32,
borderRadius: 26, borderRadius: 26,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -247,21 +475,78 @@ const styles = StyleSheet.create({
cardsContainer: { cardsContainer: {
gap: 18, gap: 18,
}, },
card: { carouselContainer: {
marginBottom: 24,
},
carouselCard: {
width: '100%',
},
carouselTouchable: {
flex: 1,
},
carouselProgressCard: {
width: '100%',
},
carouselIndicators: {
marginTop: 18,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
carouselDot: {
width: DOT_BASE_SIZE,
height: DOT_BASE_SIZE,
borderRadius: DOT_BASE_SIZE / 2,
marginHorizontal: 4,
backgroundColor: 'transparent',
},
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: {
borderRadius: 28, borderRadius: 28,
padding: 18, padding: 18,
alignItems: 'center',
shadowOffset: { width: 0, height: 16 }, shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.18, shadowOpacity: 0.18,
shadowRadius: 24, shadowRadius: 24,
elevation: 6, elevation: 6,
position: 'relative',
overflow: 'hidden',
},
cardInner: {
flexDirection: 'row',
alignItems: 'center',
}, },
cardImage: { cardImage: {
width: CARD_IMAGE_WIDTH, width: CARD_IMAGE_WIDTH,
height: CARD_IMAGE_HEIGHT, height: CARD_IMAGE_HEIGHT,
borderRadius: 22, borderRadius: 22,
}, },
cardMedia: {
borderRadius: 22,
overflow: 'hidden',
position: 'relative',
},
cardContent: { cardContent: {
flex: 1, flex: 1,
marginLeft: 16, marginLeft: 16,
@@ -271,6 +556,7 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
marginBottom: 4, marginBottom: 4,
}, },
cardDate: { cardDate: {
fontSize: 13, fontSize: 13,
fontWeight: '500', fontWeight: '500',
@@ -280,6 +566,40 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontWeight: '500', fontWeight: '500',
}, },
cardExpired: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(148, 163, 184, 0.22)',
},
cardExpiredText: {
opacity: 0.7,
},
cardDimOverlay: {
...StyleSheet.absoluteFillObject,
borderRadius: 28,
},
cardImageOverlay: {
...StyleSheet.absoluteFillObject,
},
expiredBadge: {
position: 'absolute',
left: 12,
bottom: 12,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(12, 16, 28, 0.45)',
},
expiredBadgeText: {
fontSize: 12,
fontWeight: '600',
color: '#f7f9ff',
letterSpacing: 0.3,
},
cardProgress: {
marginTop: 8,
fontSize: 13,
fontWeight: '600',
},
avatarRow: { avatarRow: {
flexDirection: 'row', flexDirection: 'row',
marginTop: 16, marginTop: 16,

View File

@@ -19,12 +19,14 @@ import { createWaterRecordAction } from '@/store/waterSlice';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health'; import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React from 'react'; import React, { useEffect } from 'react';
import { DialogProvider } from '@/components/ui/DialogProvider'; import { DialogProvider } from '@/components/ui/DialogProvider';
import { ToastProvider } from '@/contexts/ToastContext'; import { ToastProvider } from '@/contexts/ToastContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS } from '@/services/api'; import { STORAGE_KEYS } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { fetchChallenges } from '@/store/challengesSlice';
import AsyncStorage from '@/utils/kvStore'; import AsyncStorage from '@/utils/kvStore';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@@ -33,10 +35,17 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { profile } = useAppSelector((state) => state.user); const { profile } = useAppSelector((state) => state.user);
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
const { isLoggedIn } = useAuthGuard()
// 初始化快捷动作处理 // 初始化快捷动作处理
useQuickActions(); useQuickActions();
useEffect(() => {
if (isLoggedIn) {
dispatch(fetchChallenges());
}
}, [isLoggedIn]);
React.useEffect(() => { React.useEffect(() => {
const loadUserData = async () => { const loadUserData = async () => {
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态 // 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
@@ -127,6 +136,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
loadUserData(); loadUserData();
initHealthPermissions(); initHealthPermissions();
initializeNotifications(); initializeNotifications();
// 冷启动时清空 AI 教练会话缓存 // 冷启动时清空 AI 教练会话缓存
clearAiCoachSessionCache(); clearAiCoachSessionCache();

View File

@@ -1,13 +0,0 @@
import { Stack } from 'expo-router';
import React from 'react';
export default function ChallengeLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="day" />
</Stack>
);
}

View File

@@ -1,176 +0,0 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { completeDay, setCustom } from '@/store/challengeSlice';
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function ChallengeDayScreen() {
const { day } = useLocalSearchParams<{ day: string }>();
const router = useRouter();
const dispatch = useAppDispatch();
const challenge = useAppSelector((s) => (s as any).challenge);
const dayNumber = Math.max(1, Math.min(30, parseInt(String(day || '1'), 10)));
const dayState = challenge?.days?.[dayNumber - 1];
const [currentSetIndexByExercise, setCurrentSetIndexByExercise] = useState<Record<string, number>>({});
const [custom, setCustomLocal] = useState<ExerciseCustomConfig[]>(dayState?.custom || []);
const isLocked = dayState?.status === 'locked';
const isCompleted = dayState?.status === 'completed';
const plan = dayState?.plan;
// 不再强制所有动作完成,始终允许完成
const canFinish = true;
const handleNextSet = (ex: Exercise) => {
const curr = currentSetIndexByExercise[ex.key] ?? 0;
if (curr < ex.sets.length) {
setCurrentSetIndexByExercise((prev) => ({ ...prev, [ex.key]: curr + 1 }));
}
};
const handleComplete = async () => {
// 持久化自定义配置
await dispatch(setCustom({ dayNumber, custom: custom }));
await dispatch(completeDay(dayNumber));
router.back();
};
const updateCustom = (key: string, partial: Partial<ExerciseCustomConfig>) => {
setCustomLocal((prev) => {
const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c));
return next;
});
};
if (!plan) {
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}><Text>...</Text></View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}>
<HeaderBar title={`${plan.dayNumber}`} onBack={() => router.back()} withSafeTop={false} transparent />
<Text style={styles.title}>{plan.title}</Text>
<Text style={styles.subtitle}>{plan.focus}</Text>
<FlatList
data={plan.exercises}
keyExtractor={(item) => item.key}
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 120 }}
renderItem={({ item }) => {
const doneSets = currentSetIndexByExercise[item.key] ?? 0;
const conf = custom.find((c) => c.key === item.key);
const targetSets = conf?.sets ?? item.sets.length;
const perSetDuration = conf?.durationSec ?? item.sets[0]?.durationSec ?? 40;
return (
<View style={styles.exerciseCard}>
<View style={styles.exerciseHeader}>
<Text style={styles.exerciseName}>{item.name}</Text>
<Text style={styles.exerciseDesc}>{item.description}</Text>
</View>
<View style={styles.controlsRow}>
<TouchableOpacity style={[styles.toggleBtn, conf?.enabled === false && styles.toggleBtnOff]} onPress={() => updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}>
<Text style={styles.toggleBtnText}>{conf?.enabled === false ? '已关闭' : '已启用'}</Text>
</TouchableOpacity>
<View style={styles.counterBox}>
<Text style={styles.counterLabel}></Text>
<View style={styles.counterRow}>
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
<Text style={styles.counterValue}>{conf?.sets ?? targetSets}</Text>
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
</View>
</View>
<View style={styles.counterBox}>
<Text style={styles.counterLabel}>/</Text>
<View style={styles.counterRow}>
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
<Text style={styles.counterValue}>{conf?.durationSec ?? perSetDuration}s</Text>
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
</View>
</View>
</View>
<View style={styles.setsRow}>
{Array.from({ length: targetSets }).map((_, idx) => (
<View key={idx} style={[styles.setPill, idx < doneSets ? styles.setPillDone : styles.setPillTodo]}>
<Text style={[styles.setPillText, idx < doneSets ? styles.setPillTextDone : styles.setPillTextTodo]}>
{perSetDuration}s
</Text>
</View>
))}
</View>
<TouchableOpacity style={styles.nextSetBtn} onPress={() => handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}>
<Text style={styles.nextSetText}>{doneSets >= item.sets.length ? '本动作完成' : '完成一组'}</Text>
</TouchableOpacity>
{item.tips && (
<View style={styles.tipsBox}>
{item.tips.map((t: string, i: number) => (
<Text key={i} style={styles.tipText}> {t}</Text>
))}
</View>
)}
</View>
);
}}
/>
<View style={styles.bottomBar}>
<TouchableOpacity style={[styles.finishBtn, !canFinish && { opacity: 0.5 }]} disabled={!canFinish || isLocked || isCompleted} onPress={handleComplete}>
<Text style={styles.finishBtnText}>{isCompleted ? '已完成' : '完成今日训练'}</Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
container: { flex: 1, backgroundColor: '#F7F8FA' },
header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
title: { marginTop: 6, fontSize: 20, fontWeight: '800', color: '#1A1A1A' },
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
exerciseCard: {
backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12,
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
},
exerciseHeader: { marginBottom: 8 },
exerciseName: { fontSize: 16, fontWeight: '800', color: '#111827' },
exerciseDesc: { marginTop: 4, fontSize: 12, color: '#6B7280' },
setsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 },
toggleBtn: { backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
toggleBtnOff: { backgroundColor: '#9CA3AF' },
toggleBtnText: { color: '#FFFFFF', fontWeight: '700' },
counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
counterLabel: { fontSize: 10, color: '#6B7280' },
counterRow: { flexDirection: 'row', alignItems: 'center' },
counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
counterBtnText: { fontWeight: '800', color: '#111827' },
counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
setPill: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 },
setPillTodo: { backgroundColor: '#F3F4F6' },
setPillDone: { backgroundColor: Colors.light.accentGreen },
setPillText: { fontSize: 12, fontWeight: '700' },
setPillTextTodo: { color: '#6B7280' },
setPillTextDone: { color: '#192126' },
nextSetBtn: { marginTop: 10, alignSelf: 'flex-start', backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
nextSetText: { color: '#FFFFFF', fontWeight: '700' },
tipsBox: { marginTop: 10, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 10 },
tipText: { fontSize: 12, color: '#6B7280', lineHeight: 18 },
bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, backgroundColor: 'transparent' },
finishBtn: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
finishBtnText: { color: '#192126', fontWeight: '800', fontSize: 16 },
});

View File

@@ -1,142 +0,0 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { initChallenge } from '@/store/challengeSlice';
import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo } from 'react';
import { Dimensions, FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function ChallengeHomeScreen() {
const dispatch = useAppDispatch();
const router = useRouter();
const { ensureLoggedIn } = useAuthGuard();
const challenge = useAppSelector((s) => (s as any).challenge);
useEffect(() => {
dispatch(initChallenge());
}, [dispatch]);
const progress = useMemo(() => {
const total = challenge?.days?.length || 30;
const done = challenge?.days?.filter((d: any) => d.status === 'completed').length || 0;
return total ? done / total : 0;
}, [challenge?.days]);
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}>
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
<Text style={styles.subtitle}> · </Text>
{/* 进度环与统计 */}
<View style={styles.summaryCard}>
<View style={styles.summaryLeft}>
<View style={styles.progressPill}>
<View style={[styles.progressFill, { width: `${Math.round((progress || 0) * 100)}%` }]} />
</View>
<Text style={styles.progressText}>{Math.round((progress || 0) * 100)}%</Text>
</View>
<View style={styles.summaryRight}>
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{challenge?.streak ?? 0}</Text> </Text>
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0}</Text> / 30 </Text>
</View>
</View>
{/* 日历格子(简单 6x5 网格) */}
<FlatList
data={challenge?.days || []}
keyExtractor={(item) => String(item.plan.dayNumber)}
numColumns={5}
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: 40 }}
renderItem={({ item }) => {
const { plan, status } = item;
const isLocked = status === 'locked';
const isCompleted = status === 'completed';
const minutes = estimateSessionMinutesWithCustom(plan, item.custom);
return (
<TouchableOpacity
disabled={isLocked}
onPress={async () => {
if (!(await ensureLoggedIn({ redirectTo: '/challenge', redirectParams: {} }))) return;
router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } });
}}
style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]}
activeOpacity={0.8}
>
<Text style={[styles.dayNumber, isLocked && styles.dayNumberLocked]}>{plan.dayNumber}</Text>
<Text style={styles.dayMinutes}>{minutes}</Text>
{isCompleted && <Ionicons name="checkmark-circle" size={18} color="#10B981" style={{ position: 'absolute', top: 6, right: 6 }} />}
{isLocked && <Ionicons name="lock-closed" size={16} color="#9CA3AF" style={{ position: 'absolute', top: 6, right: 6 }} />}
</TouchableOpacity>
);
}}
/>
{/* 底部 CTA */}
<View style={styles.bottomBar}>
<TouchableOpacity style={styles.startButton} onPress={async () => {
if (!(await ensureLoggedIn({ redirectTo: '/challenge' }))) return;
router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d: any) => d.status === 'available')?.plan.dayNumber) || 1) } });
}}>
<Text style={styles.startButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
);
}
const { width } = Dimensions.get('window');
const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
container: { flex: 1, backgroundColor: '#F7F8FA' },
header: { paddingHorizontal: 20, paddingTop: 10 },
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
headerTitle: { fontSize: 22, fontWeight: '800', color: '#1A1A1A' },
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
summaryCard: {
marginTop: 16,
marginHorizontal: 20,
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
},
summaryLeft: { flexDirection: 'row', alignItems: 'center' },
progressPill: { width: 120, height: 10, borderRadius: 999, backgroundColor: '#E5E7EB', overflow: 'hidden' },
progressFill: { height: '100%', backgroundColor: Colors.light.accentGreen },
progressText: { marginLeft: 12, fontWeight: '700', color: '#111827' },
summaryRight: {},
summaryItem: { fontSize: 12, color: '#6B7280' },
summaryItemValue: { fontWeight: '800', color: '#111827' },
dayCell: {
width: cellSize,
height: cellSize,
borderRadius: 16,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
},
dayCellLocked: { backgroundColor: '#F3F4F6' },
dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
dayNumber: { fontWeight: '800', color: '#111827', fontSize: 16 },
dayNumberLocked: { color: '#9CA3AF' },
dayMinutes: { marginTop: 4, fontSize: 12, color: '#6B7280' },
bottomBar: { padding: 20 },
startButton: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
startButtonText: { color: '#192126', fontWeight: '800', fontSize: 16 },
});

View File

@@ -1,683 +0,0 @@
import { CHALLENGES, type Challenge } from '@/app/(tabs)/challenges';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useMemo, useState } from 'react';
import {
Dimensions,
Image,
Platform,
ScrollView,
Share,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.86;
const BADGE_SIZE = 120;
type ChallengeDetail = {
badgeImage: string;
periodLabel: string;
durationLabel: string;
requirementLabel: string;
summary?: string;
participantsCount: number;
rankingDescription?: string;
rankings: Record<string, RankingItem[]>;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
};
type RankingItem = {
id: string;
name: string;
avatar: string;
metric: string;
badge?: string;
};
const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
'hydration-hippo': {
badgeImage:
'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80',
periodLabel: '9月01日 - 9月30日 · 剩余 4 天',
durationLabel: '30 天',
requirementLabel: '喝水 1500ml 15 天以上',
summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。',
participantsCount: 9009,
rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。',
rankings: {
all: [
{
id: 'all-1',
name: '湖光暮色',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,200 ml',
badge: '金冠冠军',
},
{
id: 'all-2',
name: '温柔潮汐',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,980 ml',
},
{
id: 'all-3',
name: '晨雾河岸',
avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,860 ml',
},
],
male: [
{
id: 'male-1',
name: '北岸微风',
avatar: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,120 ml',
},
{
id: 'male-2',
name: '静水晚霞',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,940 ml',
},
],
female: [
{
id: 'female-1',
name: '露珠初晓',
avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,060 ml',
},
{
id: 'female-2',
name: '桔梗水语',
avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,880 ml',
},
],
},
highlightTitle: '分享一次,免费参与',
highlightSubtitle: '解锁高级会员,无限加入挑战',
ctaLabel: '马上分享激励好友',
},
};
const DEFAULT_DETAIL: ChallengeDetail = {
badgeImage: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80',
periodLabel: '本周进行中',
durationLabel: '30 天',
requirementLabel: '保持专注完成每日任务',
participantsCount: 3200,
highlightTitle: '立即参加,点燃动力',
highlightSubtitle: '邀请好友一起坚持,更容易收获成果',
ctaLabel: '立即加入挑战',
rankings: {
all: [],
},
};
const SEGMENTS = [
{ key: 'all', label: '全部' },
{ key: 'male', label: '男生' },
{ key: 'female', label: '女生' },
] as const;
type SegmentKey = (typeof SEGMENTS)[number]['key'];
export default function ChallengeDetailScreen() {
const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const challenge = useMemo<Challenge | undefined>(() => {
if (!id) return undefined;
return CHALLENGES.find((item) => item.id === id);
}, [id]);
const detail = useMemo<ChallengeDetail>(() => {
if (!id) return DEFAULT_DETAIL;
return DETAIL_PRESETS[id] ?? {
...DEFAULT_DETAIL,
periodLabel: challenge?.dateRange ?? DEFAULT_DETAIL.periodLabel,
highlightTitle: `加入 ${challenge?.title ?? '挑战'}`,
};
}, [challenge?.dateRange, challenge?.title, id]);
const [segment, setSegment] = useState<SegmentKey>('all');
const rankingData = detail.rankings[segment] ?? detail.rankings.all ?? [];
const handleShare = async () => {
if (!challenge) {
return;
}
try {
await Share.share({
title: challenge.title,
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
url: challenge.image,
});
} catch (error) {
console.warn('分享失败', error);
}
};
const handleJoin = () => {
// 当前没有具体业务流程,先回退到挑战列表
router.back();
};
if (!challenge) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']} >
<StatusBar barStyle="light-content" />
<View
pointerEvents="box-none"
style={[styles.headerOverlay, { paddingTop: insets.top }]}
>
<HeaderBar
title=""
tone="light"
transparent
withSafeTop={false}
right={
<TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</TouchableOpacity>
}
/>
</View>
<ScrollView
style={styles.scrollView}
bounces
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent]}
>
<View style={styles.heroContainer}>
<Image source={{ uri: challenge.image }} style={styles.heroImage} resizeMode="cover" />
<LinearGradient
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
style={StyleSheet.absoluteFillObject}
/>
</View>
<View style={styles.badgeWrapper}>
<View style={styles.badgeShadow}>
<Image source={{ uri: detail.badgeImage }} style={styles.badgeImage} resizeMode="cover" />
</View>
</View>
<View style={styles.headerTextBlock}>
<Text style={styles.periodLabel}>{detail.periodLabel}</Text>
<Text style={styles.title}>{challenge.title}</Text>
{detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null}
</View>
<View style={styles.detailCard}>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.dateRange}</Text>
<Text style={styles.detailMeta}>{detail.durationLabel}</Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{detail.requirementLabel}</Text>
<Text style={styles.detailMeta}></Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
</View>
<View style={[styles.detailTextWrapper, { flex: 1 }]}
>
<Text style={styles.detailLabel}>{detail.participantsCount.toLocaleString('zh-CN')} </Text>
<View style={styles.avatarRow}>
{challenge.avatars.slice(0, 6).map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[styles.avatar, index > 0 && styles.avatarOffset]}
/>
))}
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity>
<Text style={styles.sectionAction}></Text>
</TouchableOpacity>
</View>
{detail.rankingDescription ? (
<Text style={styles.sectionSubtitle}>{detail.rankingDescription}</Text>
) : null}
<View style={styles.segmentedControl}>
{SEGMENTS.map(({ key, label }) => {
const isActive = segment === key;
const disabled = !(detail.rankings[key] && detail.rankings[key].length);
return (
<TouchableOpacity
key={key}
style={[styles.segmentButton, isActive && styles.segmentButtonActive, disabled && styles.segmentDisabled]}
activeOpacity={disabled ? 1 : 0.8}
onPress={() => {
if (disabled) return;
setSegment(key);
}}
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive, disabled && styles.segmentLabelDisabled]}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
<View style={styles.rankingCard}>
{rankingData.length ? (
rankingData.map((item, index) => (
<View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}>
<View style={styles.rankingOrderCircle}>
<Text style={styles.rankingOrder}>{index + 1}</Text>
</View>
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
<View style={styles.rankingInfo}>
<Text style={styles.rankingName}>{item.name}</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
))
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
</View>
<View style={styles.highlightCard}>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFillObject}
/>
<Text style={styles.highlightTitle}>{detail.highlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{detail.highlightSubtitle}</Text>
<TouchableOpacity style={styles.highlightButton} activeOpacity={0.9} onPress={handleJoin}>
<Text style={styles.highlightButtonLabel}>{detail.ctaLabel}</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f3f4fb',
},
headerOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
zIndex: 20,
},
heroContainer: {
height: HERO_HEIGHT,
width: '100%',
overflow: 'hidden',
borderBottomLeftRadius: 36,
borderBottomRightRadius: 36,
},
heroImage: {
width: '100%',
height: '100%',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: Platform.select({ ios: 40, default: 28 }),
},
badgeWrapper: {
alignItems: 'center',
marginTop: -BADGE_SIZE / 2,
},
badgeShadow: {
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: BADGE_SIZE / 2,
backgroundColor: '#fff',
padding: 12,
shadowColor: 'rgba(17, 24, 39, 0.2)',
shadowOpacity: 0.25,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 12,
},
badgeImage: {
flex: 1,
borderRadius: BADGE_SIZE / 2,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: 24,
alignItems: 'center',
},
periodLabel: {
fontSize: 14,
color: '#596095',
letterSpacing: 0.2,
},
title: {
marginTop: 10,
fontSize: 24,
fontWeight: '800',
color: '#1c1f3a',
textAlign: 'center',
},
summary: {
marginTop: 12,
fontSize: 14,
lineHeight: 20,
color: '#7080b4',
textAlign: 'center',
},
detailCard: {
marginTop: 28,
marginHorizontal: 20,
padding: 20,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: 12 },
elevation: 8,
gap: 20,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
},
detailIconWrapper: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: '#EFF1FF',
alignItems: 'center',
justifyContent: 'center',
},
detailTextWrapper: {
marginLeft: 14,
},
detailLabel: {
fontSize: 15,
fontWeight: '600',
color: '#1c1f3a',
},
detailMeta: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
},
avatarRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
},
avatar: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 2,
borderColor: '#fff',
},
avatarOffset: {
marginLeft: -12,
},
moreAvatarButton: {
marginLeft: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#EEF0FF',
},
moreAvatarText: {
fontSize: 12,
color: '#4F5BD5',
fontWeight: '600',
},
sectionHeader: {
marginTop: 36,
marginHorizontal: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
},
sectionAction: {
fontSize: 13,
fontWeight: '600',
color: '#5F6BF0',
},
sectionSubtitle: {
marginTop: 8,
marginHorizontal: 24,
fontSize: 13,
color: '#6f7ba7',
lineHeight: 18,
},
segmentedControl: {
marginTop: 20,
marginHorizontal: 24,
borderRadius: 20,
backgroundColor: '#EAECFB',
padding: 4,
flexDirection: 'row',
},
segmentButton: {
flex: 1,
paddingVertical: 8,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
segmentButtonActive: {
backgroundColor: '#fff',
shadowColor: 'rgba(79, 91, 213, 0.25)',
shadowOpacity: 0.3,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 4,
},
segmentDisabled: {
opacity: 0.5,
},
segmentLabel: {
fontSize: 13,
fontWeight: '600',
color: '#6372C6',
},
segmentLabelActive: {
color: '#4F5BD5',
},
segmentLabelDisabled: {
color: '#9AA3CF',
},
rankingCard: {
marginTop: 20,
marginHorizontal: 24,
borderRadius: 24,
backgroundColor: '#ffffff',
paddingVertical: 10,
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
},
rankingRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
},
rankingRowDivider: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#E5E7FF',
},
rankingOrderCircle: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
marginRight: 12,
},
rankingOrder: {
fontSize: 15,
fontWeight: '700',
color: '#4F5BD5',
},
rankingAvatar: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 14,
},
rankingInfo: {
flex: 1,
},
rankingName: {
fontSize: 15,
fontWeight: '700',
color: '#1c1f3a',
},
rankingMetric: {
marginTop: 4,
fontSize: 13,
color: '#6f7ba7',
},
rankingBadge: {
fontSize: 12,
color: '#A67CFF',
fontWeight: '700',
},
emptyRanking: {
paddingVertical: 40,
alignItems: 'center',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
highlightCard: {
marginTop: 32,
marginHorizontal: 24,
borderRadius: 28,
paddingVertical: 28,
paddingHorizontal: 24,
overflow: 'hidden',
},
highlightTitle: {
fontSize: 18,
fontWeight: '800',
color: '#ffffff',
},
highlightSubtitle: {
marginTop: 10,
fontSize: 14,
color: 'rgba(255,255,255,0.85)',
lineHeight: 20,
},
highlightButton: {
marginTop: 22,
backgroundColor: 'rgba(255,255,255,0.18)',
paddingVertical: 12,
borderRadius: 22,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(247,248,255,0.5)',
},
highlightButtonLabel: {
fontSize: 15,
fontWeight: '700',
color: '#ffffff',
},
circularButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.24)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.45)',
},
shareIcon: {
fontSize: 18,
color: '#ffffff',
fontWeight: '700',
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 16,
textAlign: 'center',
},
});

View File

@@ -0,0 +1,822 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchChallengeDetail,
fetchChallengeRankings,
joinChallenge,
leaveChallenge,
reportChallengeProgress,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
selectChallengeRankingList,
selectJoinError,
selectJoinStatus,
selectLeaveError,
selectLeaveStatus,
selectProgressStatus
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Dimensions,
Platform,
ScrollView,
Share,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.76;
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
const formatMonthDay = (value?: string): string | undefined => {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return `${date.getMonth() + 1}${date.getDate()}`;
};
const buildDateRangeLabel = (challenge?: {
startAt?: string;
endAt?: string;
periodLabel?: string;
durationLabel?: string;
}): string => {
if (!challenge) return '';
const startLabel = formatMonthDay(challenge.startAt);
const endLabel = formatMonthDay(challenge.endAt);
if (startLabel && endLabel) {
return `${startLabel} - ${endLabel}`;
}
return challenge.periodLabel ?? challenge.durationLabel ?? '';
};
const formatParticipantsLabel = (count?: number): string => {
if (typeof count !== 'number') return '持续更新中';
return `${count.toLocaleString('zh-CN')} 人正在参与`;
};
export default function ChallengeDetailScreen() {
const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter();
const dispatch = useAppDispatch();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const { ensureLoggedIn } = useAuthGuard();
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
const joinStatusSelector = useMemo(() => (id ? selectJoinStatus(id) : undefined), [id]);
const joinStatus = useAppSelector((state) => (joinStatusSelector ? joinStatusSelector(state) : 'idle'));
const joinErrorSelector = useMemo(() => (id ? selectJoinError(id) : undefined), [id]);
const joinError = useAppSelector((state) => (joinErrorSelector ? joinErrorSelector(state) : undefined));
const leaveStatusSelector = useMemo(() => (id ? selectLeaveStatus(id) : undefined), [id]);
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
useEffect(() => {
const getData = async (id: string) => {
try {
await dispatch(fetchChallengeDetail(id)).unwrap;
} catch (error) {
}
}
if (id) {
getData(id);
}
}, [dispatch, id]);
useEffect(() => {
if (id && !rankingList) {
void dispatch(fetchChallengeRankings({ id }));
}
}, [dispatch, id, rankingList]);
const [showCelebration, setShowCelebration] = useState(false);
useEffect(() => {
if (!showCelebration) {
return;
}
const timer = setTimeout(() => {
setShowCelebration(false);
}, 2400);
return () => {
clearTimeout(timer);
};
}, [showCelebration]);
const progress = challenge?.progress;
const rankingData = useMemo(() => {
const source = rankingList?.items ?? challenge?.rankings ?? [];
return source.slice(0, 10);
}, [challenge?.rankings, rankingList?.items]);
const participantAvatars = useMemo(
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
[rankingData],
);
const handleViewAllRanking = () => {
if (!id) {
return;
}
router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } });
};
const dateRangeLabel = useMemo(
() =>
buildDateRangeLabel({
startAt: challenge?.startAt,
endAt: challenge?.endAt,
periodLabel: challenge?.periodLabel,
durationLabel: challenge?.durationLabel,
}),
[challenge?.startAt, challenge?.endAt, challenge?.periodLabel, challenge?.durationLabel],
);
const handleShare = async () => {
if (!challenge) {
return;
}
try {
await Share.share({
title: challenge.title,
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
url: challenge.image,
});
} catch (error) {
console.warn('分享失败', error);
}
};
const handleJoin = async () => {
if (!id || joinStatus === 'loading') {
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
// 如果未登录,用户会被重定向到登录页面
return;
}
try {
await dispatch(joinChallenge(id));
setShowCelebration(true)
} catch (error) {
Toast.error('加入挑战失败')
}
};
const handleLeave = async () => {
if (!id || leaveStatus === 'loading') {
return;
}
try {
await dispatch(leaveChallenge(id)).unwrap();
await dispatch(fetchChallengeDetail(id)).unwrap();
} catch (error) {
Toast.error('退出挑战失败');
}
};
const handleLeaveConfirm = () => {
if (!id || leaveStatus === 'loading') {
return;
}
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
{ text: '取消', style: 'cancel' },
{
text: '退出挑战',
style: 'destructive',
onPress: () => {
void handleLeave();
},
},
]);
};
const handleProgressReport = () => {
if (!id || progressStatus === 'loading') {
return;
}
dispatch(reportChallengeProgress({ id }));
};
const isJoined = challenge?.isJoined ?? false;
const isLoadingInitial = detailStatus === 'loading' && !challenge;
if (!id) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</SafeAreaView>
);
}
if (isLoadingInitial) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}></Text>
</View>
</SafeAreaView>
);
}
if (!challenge) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '未找到该挑战,稍后再试试吧。'}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallengeDetail(id))}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
const isUpcoming = challenge.status === 'upcoming';
const isExpired = challenge.status === 'expired';
const upcomingStartLabel = formatMonthDay(challenge.startAt);
const upcomingHighlightTitle = '挑战即将开始';
const upcomingHighlightSubtitle = upcomingStartLabel
? `${upcomingStartLabel} 开始,敬请期待`
: '挑战即将开启,敬请期待';
const upcomingCtaLabel = '挑战即将开始';
const expiredEndLabel = formatMonthDay(challenge.endAt);
const expiredHighlightTitle = '挑战已结束';
const expiredHighlightSubtitle = expiredEndLabel
? `${expiredEndLabel} 已截止,期待下一次挑战`
: '本轮挑战已结束,期待下一次挑战';
const expiredCtaLabel = '挑战已结束';
const leaveHighlightTitle = '先别急着离开';
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
let floatingHighlightTitle = highlightTitle;
let floatingHighlightSubtitle = highlightSubtitle;
let floatingCtaLabel = joinCtaLabel;
let floatingOnPress: (() => void) | undefined = handleJoin;
let floatingDisabled = joinStatus === 'loading';
let floatingError = joinError;
let isDisabledButtonState = false;
if (isJoined) {
floatingHighlightTitle = leaveHighlightTitle;
floatingHighlightSubtitle = leaveHighlightSubtitle;
floatingCtaLabel = leaveCtaLabel;
floatingOnPress = handleLeaveConfirm;
floatingDisabled = leaveStatus === 'loading';
floatingError = leaveError;
}
if (isUpcoming) {
floatingHighlightTitle = upcomingHighlightTitle;
floatingHighlightSubtitle = upcomingHighlightSubtitle;
floatingCtaLabel = upcomingCtaLabel;
floatingOnPress = undefined;
floatingDisabled = true;
floatingError = undefined;
isDisabledButtonState = true;
}
if (isExpired) {
floatingHighlightTitle = expiredHighlightTitle;
floatingHighlightSubtitle = expiredHighlightSubtitle;
floatingCtaLabel = expiredCtaLabel;
floatingOnPress = undefined;
floatingDisabled = true;
floatingError = undefined;
isDisabledButtonState = true;
}
const floatingGradientColors = isDisabledButtonState ? CTA_DISABLED_GRADIENT : CTA_GRADIENT;
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
return (
<View style={styles.safeArea}>
<StatusBar barStyle="light-content" />
<View style={styles.container}>
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
<HeaderBar
title=""
backColor="white"
tone="light"
transparent
withSafeTop={false}
// right={
// <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
// <Ionicons name="share-social-outline" size={20} color="#ffffff" />
// </TouchableOpacity>
// }
/>
</View>
<ScrollView
style={styles.scrollView}
bounces
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 160) + insets.bottom },
]}
>
<View style={styles.heroContainer}>
<Image source={{ uri: challenge.image }} style={styles.heroImage} cachePolicy={'memory-disk'} />
<LinearGradient
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
style={StyleSheet.absoluteFillObject}
/>
</View>
<View style={styles.headerTextBlock}>
<Text style={styles.title}>{challenge.title}</Text>
{challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null}
{inlineErrorMessage ? (
<View style={styles.inlineError}>
<Ionicons name="warning-outline" size={14} color="#FF6B6B" />
<Text style={styles.inlineErrorText}>{inlineErrorMessage}</Text>
</View>
) : null}
</View>
{progress ? (
<ChallengeProgressCard
title={challenge.title}
endAt={challenge.endAt}
progress={progress}
style={styles.progressCardWrapper}
/>
) : null}
<View style={styles.detailCard}>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{dateRangeLabel}</Text>
<Text style={styles.detailMeta}>{challenge.durationLabel}</Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
<Text style={styles.detailMeta}></Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
</View>
<View style={[styles.detailTextWrapper, { flex: 1 }]}>
<Text style={styles.detailLabel}>{participantsLabel}</Text>
{participantAvatars.length ? (
<View style={styles.avatarRow}>
{participantAvatars.map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[styles.avatar, index > 0 && styles.avatarOffset]}
cachePolicy={'memory-disk'}
/>
))}
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
</TouchableOpacity>
) : null}
</View>
) : null}
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
<Text style={styles.sectionAction}></Text>
</TouchableOpacity>
</View>
{challenge.rankingDescription ? (
<Text style={styles.sectionSubtitle}>{challenge.rankingDescription}</Text>
) : null}
<View style={styles.rankingCard}>
{rankingData.length ? (
rankingData.map((item, index) => (
<ChallengeRankingItem
key={item.id ?? index}
item={item}
index={index}
showDivider={index > 0}
unit={challenge?.unit}
/>
))
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
</View>
</ScrollView>
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}>
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</BlurView>
</View>
</View>
{showCelebration && (
<View pointerEvents="none" style={styles.celebrationOverlay}>
<LottieView
autoPlay
loop={false}
source={require('@/assets/lottie/Confetti.json')}
style={styles.celebrationAnimation}
/>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
backgroundColor: '#f3f4fb',
},
headerOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
zIndex: 20,
},
heroContainer: {
height: HERO_HEIGHT,
width: '100%',
overflow: 'hidden',
position: 'absolute',
top: 0
},
heroImage: {
width: '100%',
height: '100%',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: Platform.select({ ios: 40, default: 28 }),
},
progressCardWrapper: {
marginTop: 20,
marginHorizontal: 24,
},
floatingCTAContainer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 20,
},
floatingCTABlur: {
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.85)',
},
floatingCTAContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 20,
},
highlightCopy: {
flex: 1,
marginRight: 16,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: HERO_HEIGHT - 60,
alignItems: 'center',
},
periodLabel: {
fontSize: 14,
color: '#596095',
letterSpacing: 0.2,
},
title: {
marginTop: 10,
fontSize: 24,
fontWeight: '800',
color: '#1c1f3a',
textAlign: 'center',
},
summary: {
marginTop: 12,
fontSize: 14,
lineHeight: 20,
color: '#7080b4',
textAlign: 'center',
},
inlineError: {
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 12,
backgroundColor: 'rgba(255, 107, 107, 0.12)',
flexDirection: 'row',
alignItems: 'center',
},
inlineErrorText: {
marginLeft: 6,
fontSize: 12,
color: '#FF6B6B',
flexShrink: 1,
},
detailCard: {
marginTop: 28,
marginHorizontal: 20,
padding: 20,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: 12 },
elevation: 8,
gap: 20,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
},
detailIconWrapper: {
width: 42,
height: 42,
alignItems: 'center',
justifyContent: 'center',
},
detailTextWrapper: {
marginLeft: 14,
},
detailLabel: {
fontSize: 15,
fontWeight: '600',
color: '#1c1f3a',
},
detailMeta: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
},
avatarRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
},
avatar: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 2,
borderColor: '#fff',
},
avatarOffset: {
marginLeft: -12,
},
moreAvatarButton: {
marginLeft: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#EEF0FF',
},
moreAvatarText: {
fontSize: 12,
color: '#4F5BD5',
fontWeight: '600',
},
sectionHeader: {
marginTop: 36,
marginHorizontal: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
},
sectionAction: {
fontSize: 13,
fontWeight: '600',
color: '#5F6BF0',
},
sectionSubtitle: {
marginTop: 8,
marginHorizontal: 24,
fontSize: 13,
color: '#6f7ba7',
lineHeight: 18,
},
rankingCard: {
marginTop: 20,
marginHorizontal: 24,
borderRadius: 24,
backgroundColor: '#ffffff',
paddingVertical: 10,
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
},
emptyRanking: {
paddingVertical: 40,
alignItems: 'center',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
highlightTitle: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
},
highlightSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#5f6a97',
lineHeight: 18,
},
ctaErrorText: {
marginTop: 8,
fontSize: 12,
color: '#FF6B6B',
},
highlightButton: {
borderRadius: 22,
overflow: 'hidden',
},
highlightButtonBackground: {
borderRadius: 22,
paddingVertical: 10,
paddingHorizontal: 18,
alignItems: 'center',
justifyContent: 'center',
},
highlightButtonLabel: {
fontSize: 14,
fontWeight: '700',
color: '#ffffff',
},
highlightButtonLabelDisabled: {
color: '#6f7799',
},
circularButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.24)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.45)',
},
shareIcon: {
fontSize: 18,
color: '#ffffff',
fontWeight: '700',
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 16,
textAlign: 'center',
},
retryButton: {
marginTop: 18,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 22,
borderWidth: 1,
},
retryText: {
fontSize: 14,
fontWeight: '600',
},
celebrationOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
zIndex: 40,
},
celebrationAnimation: {
width: width * 1.3,
height: width * 1.3,
},
});

View File

@@ -0,0 +1,298 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchChallengeDetail,
fetchChallengeRankings,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
selectChallengeRankingError,
selectChallengeRankingList,
selectChallengeRankingLoadMoreStatus,
selectChallengeRankingStatus,
} from '@/store/challengesSlice';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import {
ActivityIndicator,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function ChallengeLeaderboardScreen() {
const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter();
const dispatch = useAppDispatch();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
const rankingStatusSelector = useMemo(() => (id ? selectChallengeRankingStatus(id) : undefined), [id]);
const rankingStatus = useAppSelector((state) => (rankingStatusSelector ? rankingStatusSelector(state) : 'idle'));
const rankingLoadMoreStatusSelector = useMemo(
() => (id ? selectChallengeRankingLoadMoreStatus(id) : undefined),
[id]
);
const rankingLoadMoreStatus = useAppSelector((state) =>
rankingLoadMoreStatusSelector ? rankingLoadMoreStatusSelector(state) : 'idle'
);
const rankingErrorSelector = useMemo(() => (id ? selectChallengeRankingError(id) : undefined), [id]);
const rankingError = useAppSelector((state) => (rankingErrorSelector ? rankingErrorSelector(state) : undefined));
useEffect(() => {
if (id) {
void dispatch(fetchChallengeDetail(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && !rankingList) {
void dispatch(fetchChallengeRankings({ id }));
}
}, [dispatch, id, rankingList]);
if (!id) {
return (
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
if (detailStatus === 'loading' && !challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.loadingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
const hasMore = rankingList?.hasMore ?? false;
const isRefreshing = rankingStatus === 'loading';
const isLoadingMore = rankingLoadMoreStatus === 'loading';
const defaultPageSize = rankingList?.pageSize ?? 20;
const showInitialRankingLoading = isRefreshing && (!rankingList || rankingList.items.length === 0);
const handleRefresh = () => {
if (!id) {
return;
}
void dispatch(fetchChallengeRankings({ id, page: 1, pageSize: defaultPageSize }));
};
const handleLoadMore = () => {
if (!id || !rankingList || !hasMore || isLoadingMore || rankingStatus === 'loading') {
return;
}
void dispatch(
fetchChallengeRankings({ id, page: rankingList.page + 1, pageSize: rankingList.pageSize })
);
};
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 160;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
handleLoadMore();
}
};
if (!challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
</Text>
</View>
</View>
);
}
const rankingData = rankingList?.items ?? challenge.rankings ?? [];
const subtitle = challenge.rankingDescription ?? challenge.summary;
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colorTokens.primary}
/>
}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<View style={styles.pageHeader}>
<Text style={styles.challengeTitle}>{challenge.title}</Text>
{subtitle ? <Text style={styles.challengeSubtitle}>{subtitle}</Text> : null}
{challenge.progress ? (
<ChallengeProgressCard
title={challenge.title}
endAt={challenge.endAt}
progress={challenge.progress}
style={styles.progressCardWrapper}
/>
) : null}
</View>
<View style={styles.rankingCard}>
{showInitialRankingLoading ? (
<View style={styles.rankingLoading}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
) : rankingData.length ? (
rankingData.map((item, index) => (
<ChallengeRankingItem
key={item.id ?? index}
item={item}
index={index}
showDivider={index > 0}
unit={challenge?.unit}
/>
))
) : rankingError ? (
<View style={styles.emptyRanking}>
<Text style={styles.rankingErrorText}>{rankingError}</Text>
</View>
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
{isLoadingMore ? (
<View style={styles.loadMoreIndicator}>
<ActivityIndicator color={colorTokens.primary} size="small" />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}></Text>
</View>
) : null}
{rankingLoadMoreStatus === 'failed' ? (
<View style={styles.loadMoreIndicator}>
<Text style={styles.loadMoreErrorText}></Text>
</View>
) : null}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
backgroundColor: 'transparent',
},
pageHeader: {
paddingHorizontal: 24,
paddingTop: 24,
},
challengeTitle: {
fontSize: 22,
fontWeight: '800',
color: '#1c1f3a',
},
challengeSubtitle: {
marginTop: 8,
fontSize: 14,
color: '#6f7ba7',
lineHeight: 20,
},
progressCardWrapper: {
marginTop: 20,
},
rankingCard: {
marginTop: 24,
marginHorizontal: 24,
borderRadius: 24,
backgroundColor: '#ffffff',
paddingVertical: 10,
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
},
emptyRanking: {
paddingVertical: 40,
alignItems: 'center',
justifyContent: 'center',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
rankingLoading: {
paddingVertical: 32,
alignItems: 'center',
justifyContent: 'center',
},
rankingErrorText: {
fontSize: 14,
color: '#eb5757',
},
loadMoreIndicator: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
loadMoreErrorText: {
fontSize: 13,
color: '#eb5757',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 14,
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 14,
textAlign: 'center',
},
});

View File

@@ -1,10 +1,15 @@
import React, { useState, useCallback, useRef } from 'react';
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { CircularRing } from './CircularRing';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { fetchActivityRingsForDate, ActivityRingsData } from '@/utils/health'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health';
import { logger } from '@/utils/logger';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { CircularRing } from './CircularRing';
type FitnessRingsCardProps = { type FitnessRingsCardProps = {
style?: any; style?: any;
@@ -21,9 +26,17 @@ export function FitnessRingsCard({
selectedDate, selectedDate,
resetToken, resetToken,
}: FitnessRingsCardProps) { }: FitnessRingsCardProps) {
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null); const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const lastReportedRef = useRef<{ date: string } | null>(null);
const joinedExerciseChallenges = useMemo(
() => challenges.filter((challenge) => challenge.type === ChallengeType.EXERCISE && challenge.isJoined && challenge.status === 'ongoing'),
[challenges]
);
// 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发 // 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发
useFocusEffect( useFocusEffect(
@@ -52,6 +65,63 @@ export function FitnessRingsCard({
}, [selectedDate]) }, [selectedDate])
); );
useEffect(() => {
if (!selectedDate || !activityData || !joinedExerciseChallenges.length) {
return;
}
if (!dayjs(selectedDate).isSame(dayjs(), 'day')) {
return;
}
const {
activeEnergyBurned,
activeEnergyBurnedGoal,
appleExerciseTime,
appleExerciseTimeGoal,
appleStandHours,
appleStandHoursGoal,
} = activityData;
if (
activeEnergyBurnedGoal <= 0 ||
appleExerciseTimeGoal <= 0 ||
appleStandHoursGoal <= 0
) {
return;
}
const allRingsClosed =
activeEnergyBurned >= activeEnergyBurnedGoal &&
appleExerciseTime >= appleExerciseTimeGoal &&
appleStandHours >= appleStandHoursGoal;
if (!allRingsClosed) {
return;
}
const dateKey = dayjs(selectedDate).format('YYYY-MM-DD');
if (lastReportedRef.current?.date === dateKey) {
return;
}
const exerciseChallenge = joinedExerciseChallenges[0];
if (!exerciseChallenge) {
return;
}
const reportProgressAsync = async () => {
try {
await dispatch(reportChallengeProgress({ id: exerciseChallenge.id, value: 1 })).unwrap();
lastReportedRef.current = { date: dateKey };
} catch (error) {
logger.warn('FitnessRingsCard: 挑战进度上报失败', { error, challengeId: exerciseChallenge.id });
}
};
reportProgressAsync();
}, [activityData, dispatch, joinedExerciseChallenges, selectedDate]);
// 使用获取到的数据或默认值 // 使用获取到的数据或默认值
const activeCalories = activityData?.activeEnergyBurned ?? 0; const activeCalories = activityData?.activeEnergyBurned ?? 0;
const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350; const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;

View File

@@ -58,13 +58,6 @@ export function StressMeter({ curDate }: StressMeterProps) {
// 使用传入的 hrvValue 进行转换 // 使用传入的 hrvValue 进行转换
const stressIndex = convertHrvToStressIndex(hrvValue); const stressIndex = convertHrvToStressIndex(hrvValue);
// 调试信息
console.log('StressMeter 调试:', {
hrvValue,
stressIndex,
progressPercentage: stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0
});
// 计算进度条位置0-100% // 计算进度条位置0-100%
// 压力指数越高,进度条越满(红色区域越多) // 压力指数越高,进度条越满(红色区域越多)
const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0; const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0;

View File

@@ -0,0 +1,262 @@
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef } from 'react';
import { Animated, Easing, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import type { ChallengeProgress } from '@/store/challengesSlice';
type ChallengeProgressCardProps = {
title: string;
endAt?: string;
progress?: ChallengeProgress;
style?: StyleProp<ViewStyle>;
backgroundColors?: [string, string];
titleColor?: string;
subtitleColor?: string;
metaColor?: string;
metaSuffixColor?: string;
accentColor?: string;
trackColor?: string;
inactiveColor?: string;
};
type RemainingTime = {
value: number;
unit: '天' | '小时';
};
const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff'];
const DEFAULT_TITLE_COLOR = '#1c1f3a';
const DEFAULT_SUBTITLE_COLOR = '#707baf';
const DEFAULT_META_COLOR = '#4F5BD5';
const DEFAULT_META_SUFFIX_COLOR = '#7a86bb';
const DEFAULT_ACCENT_COLOR = '#5E8BFF';
const DEFAULT_TRACK_COLOR = '#eceffa';
const DEFAULT_INACTIVE_COLOR = '#dfe4f6';
const clampSegments = (target: number, completed: number) => {
const segmentsCount = Math.max(1, Math.min(target, 18));
const completedSegments = Math.min(
segmentsCount,
Math.round((completed / Math.max(target, 1)) * segmentsCount)
);
return { segmentsCount, completedSegments };
};
const calculateRemainingTime = (endAt?: string): RemainingTime => {
if (!endAt) return { value: 0, unit: '天' };
const endDate = dayjs(endAt);
if (!endDate.isValid()) return { value: 0, unit: '天' };
const diffMilliseconds = endDate.diff(dayjs());
if (diffMilliseconds <= 0) {
return { value: 0, unit: '天' };
}
const diffHours = diffMilliseconds / (60 * 60 * 1000);
if (diffHours < 24) {
return { value: Math.max(1, Math.floor(diffHours)), unit: '小时' };
}
return { value: Math.floor(diffHours / 24), unit: '天' };
};
export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
title,
endAt,
progress,
style,
backgroundColors = DEFAULT_BACKGROUND,
titleColor = DEFAULT_TITLE_COLOR,
subtitleColor = DEFAULT_SUBTITLE_COLOR,
metaColor = DEFAULT_META_COLOR,
metaSuffixColor = DEFAULT_META_SUFFIX_COLOR,
accentColor = DEFAULT_ACCENT_COLOR,
trackColor = DEFAULT_TRACK_COLOR,
inactiveColor = DEFAULT_INACTIVE_COLOR,
}) => {
const hasValidProgress = Boolean(progress && progress.target && progress.target > 0);
const segmentAnimations = useRef<Animated.Value[]>([]);
const segments = useMemo(() => {
if (!hasValidProgress || !progress) return undefined;
return clampSegments(progress.target, progress.completed);
}, [hasValidProgress, progress]);
if (segments) {
if (segmentAnimations.current.length < segments.segmentsCount) {
const additional = Array.from(
{ length: segments.segmentsCount - segmentAnimations.current.length },
() => new Animated.Value(0)
);
segmentAnimations.current = [...segmentAnimations.current, ...additional];
} else if (segmentAnimations.current.length > segments.segmentsCount) {
segmentAnimations.current = segmentAnimations.current.slice(0, segments.segmentsCount);
}
} else if (segmentAnimations.current.length) {
segmentAnimations.current = [];
}
useEffect(() => {
if (!segments) return;
segmentAnimations.current.forEach((animation, index) => {
const isComplete = index < segments.completedSegments;
Animated.timing(animation, {
toValue: isComplete ? 1 : 0,
duration: isComplete ? 460 : 240,
delay: isComplete ? index * 55 : 0,
easing: isComplete ? Easing.out(Easing.cubic) : Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
});
}, [segments?.completedSegments, segments?.segmentsCount]);
const remainingTime = useMemo(() => calculateRemainingTime(endAt), [endAt]);
if (!hasValidProgress || !progress || !segments) {
return null;
}
return (
<View style={[styles.shadow, style]}>
<LinearGradient colors={backgroundColors} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.card}>
<View style={styles.headerRow}>
<View style={styles.headline}>
<Text style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title}
</Text>
</View>
<Text style={[styles.remaining, { color: subtitleColor }]}>
{remainingTime.value} {remainingTime.unit}
</Text>
</View>
<View style={styles.metaRow}>
<Text style={[styles.metaValue, { color: metaColor }]}>
{progress.completed} / {progress.target}
<Text style={[styles.metaSuffix, { color: metaSuffixColor }]}> </Text>
</Text>
</View>
<View style={[styles.track, { backgroundColor: trackColor }]}>
{Array.from({ length: segments.segmentsCount }).map((_, index) => {
const isFirst = index === 0;
const isLast = index === segments.segmentsCount - 1;
const animation = segmentAnimations.current[index];
if (!animation) {
return null;
}
const scaleY = animation.interpolate({
inputRange: [0, 1],
outputRange: [0.55, 1],
});
const scaleX = animation.interpolate({
inputRange: [0, 1],
outputRange: [0.7, 1],
});
const opacity = animation.interpolate({
inputRange: [0, 1],
outputRange: [0.25, 1],
});
return (
<View
key={`progress-segment-${index}`}
style={[
styles.segment,
{ backgroundColor: inactiveColor },
isFirst && styles.segmentFirst,
isLast && styles.segmentLast,
]}
>
<Animated.View
style={[
styles.segmentFill,
{
backgroundColor: accentColor,
opacity,
transform: [{ scaleX }, { scaleY }],
},
]}
/>
</View>
);
})}
</View>
</LinearGradient>
</View>
);
};
const styles = StyleSheet.create({
shadow: {
borderRadius: 28,
shadowColor: 'rgba(104, 119, 255, 0.25)',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.24,
shadowRadius: 28,
elevation: 12,
},
card: {
borderRadius: 28,
paddingVertical: 24,
paddingHorizontal: 22,
},
headerRow: {
flexDirection: 'row',
alignItems: 'flex-start',
},
headline: {
flex: 1,
},
title: {
fontSize: 18,
fontWeight: '700',
},
remaining: {
fontSize: 11,
fontWeight: '600',
alignSelf: 'flex-start',
},
metaRow: {
marginTop: 12,
},
metaValue: {
fontSize: 14,
fontWeight: '700',
},
metaSuffix: {
fontSize: 13,
fontWeight: '500',
},
track: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 6,
paddingVertical: 4,
},
segment: {
flex: 1,
height: 4,
borderRadius: 4,
marginHorizontal: 3,
overflow: 'hidden',
},
segmentFirst: {
marginLeft: 0,
},
segmentLast: {
marginRight: 0,
},
segmentFill: {
flex: 1,
borderRadius: 4,
},
});
export default ChallengeProgressCard;

View File

@@ -0,0 +1,143 @@
import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
type ChallengeRankingItemProps = {
item: RankingItem;
index: number;
showDivider?: boolean;
unit?: string;
};
const formatNumber = (value: number): string => {
if (Number.isInteger(value)) {
return value.toString();
}
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
};
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} 小时`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) {
console.log('unit', unit);
const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit);
const targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
const progressLabel = reportedLabel && targetLabel
? `今日 ${reportedLabel} / ${targetLabel}`
: reportedLabel
? `今日 ${reportedLabel}`
: targetLabel
? `今日目标 ${targetLabel}`
: undefined;
return (
<View style={[styles.rankingRow, showDivider && styles.rankingRowDivider]}>
<View style={styles.rankingOrderCircle}>
<Text style={styles.rankingOrder}>{index + 1}</Text>
</View>
{item.avatar ? (
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} cachePolicy="memory-disk" />
) : (
<View style={styles.rankingAvatarPlaceholder}>
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
</View>
)}
<View style={styles.rankingInfo}>
<Text style={styles.rankingName} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
{progressLabel ? (
<Text style={styles.rankingProgress} numberOfLines={1}>
{progressLabel}
</Text>
) : null}
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
rankingRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
},
rankingRowDivider: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#E5E7FF',
},
rankingOrderCircle: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
marginRight: 12,
},
rankingOrder: {
fontSize: 15,
fontWeight: '700',
color: '#4F5BD5',
},
rankingAvatar: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 14,
backgroundColor: '#EEF0FF',
},
rankingAvatarPlaceholder: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
},
rankingInfo: {
flex: 1,
},
rankingName: {
fontSize: 15,
fontWeight: '700',
color: '#1c1f3a',
},
rankingMetric: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
},
rankingProgress: {
marginTop: 2,
fontSize: 10,
color: '#8a94c1',
},
rankingBadge: {
fontSize: 12,
color: '#A67CFF',
fontWeight: '700',
},
});

View File

@@ -1,8 +1,12 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { logger } from '@/utils/logger';
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit'; import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
@@ -15,8 +19,15 @@ const SleepCard: React.FC<SleepCardProps> = ({
selectedDate, selectedDate,
style, style,
}) => { }) => {
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [sleepDuration, setSleepDuration] = useState<number | null>(null); const [sleepDuration, setSleepDuration] = useState<number | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const joinedSleepChallenges = useMemo(
() => challenges.filter((challenge) => challenge.type === ChallengeType.SLEEP && challenge.isJoined && challenge.status === 'ongoing'),
[challenges]
);
const lastReportedRef = useRef<{ date: string; value: number | null } | null>(null);
// 获取睡眠数据 // 获取睡眠数据
useEffect(() => { useEffect(() => {
@@ -38,6 +49,41 @@ const SleepCard: React.FC<SleepCardProps> = ({
loadSleepData(); loadSleepData();
}, [selectedDate]); }, [selectedDate]);
useEffect(() => {
if (!selectedDate || !sleepDuration || !joinedSleepChallenges.length) {
return;
}
// 如果当前日期不是今天,不上报
if (!dayjs(selectedDate).isSame(dayjs(), 'day')) {
return;
}
const dateKey = dayjs(selectedDate).format('YYYY-MM-DD');
const lastReport = lastReportedRef.current;
if (lastReport && lastReport.date === dateKey && lastReport.value === sleepDuration) {
return;
}
const reportProgress = async () => {
const sleepChallenge = joinedSleepChallenges.find((c) => c.type === ChallengeType.SLEEP);
if (!sleepChallenge) {
return;
}
try {
await dispatch(reportChallengeProgress({ id: sleepChallenge.id, value: sleepDuration })).unwrap();
} catch (error) {
logger.warn('SleepCard: 挑战进度上报失败', { error, challengeId: sleepChallenge.id });
}
lastReportedRef.current = { date: dateKey, value: sleepDuration };
};
reportProgress();
}, [dispatch, joinedSleepChallenges, selectedDate, sleepDuration]);
const CardContent = ( const CardContent = (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<View style={styles.cardHeaderRow}> <View style={styles.cardHeaderRow}>

View File

@@ -148,8 +148,8 @@ export const Colors = {
ornamentAccent: palette.success[100], ornamentAccent: palette.success[100],
// 背景渐变色 // 背景渐变色
backgroundGradientStart: palette.purple[25], backgroundGradientStart: palette.purple[100],
backgroundGradientEnd: palette.base.white, backgroundGradientEnd: palette.purple[25],
}, },
dark: { dark: {
// 基础文本/背景 // 基础文本/背景

View File

@@ -1,3 +1,6 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health'; import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
@@ -41,6 +44,32 @@ function createDateRange(date: string): { startDate: string; endDate: string } {
}; };
} }
const useWaterChallengeProgressReporter = () => {
const dispatch = useAppDispatch();
const allChallenges = useAppSelector(selectChallengeList);
const joinedWaterChallenges = useMemo(
() => allChallenges.filter((challenge) => challenge.type === ChallengeType.WATER && challenge.isJoined && challenge.status === 'ongoing'),
[allChallenges]
);
return useCallback(
async (value: number) => {
if (!joinedWaterChallenges.length) {
return;
}
for (const challenge of joinedWaterChallenges) {
try {
await dispatch(reportChallengeProgress({ id: challenge.id, value })).unwrap();
} catch (error) {
console.warn('挑战进度上报失败', { error, challengeId: challenge.id });
}
}
},
[dispatch, joinedWaterChallenges]
);
};
export const useWaterData = () => { export const useWaterData = () => {
// 本地状态管理 // 本地状态管理
const [loading, setLoading] = useState({ const [loading, setLoading] = useState({
@@ -152,46 +181,53 @@ export const useWaterData = () => {
}, [getWaterRecordsByDate]); }, [getWaterRecordsByDate]);
// 创建喝水记录 // 创建喝水记录
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
try {
const recordTime = recordedAt || dayjs().toISOString();
// 保存到 HealthKit const addWaterRecord = useCallback(
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime); async (amount: number, recordedAt?: string) => {
if (!healthKitSuccess) { try {
Toast.error('保存到 HealthKit 失败'); const recordTime = recordedAt || dayjs().toISOString();
const date = dayjs(recordTime).format('YYYY-MM-DD');
const isToday = dayjs(recordTime).isSame(dayjs(), 'day');
// 保存到 HealthKit
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
if (!healthKitSuccess) {
Toast.error('保存到 HealthKit 失败');
return false;
}
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(date);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget
if (isToday) {
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
await reportWaterChallengeProgress(totalAmount);
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false; return false;
} }
},
// 重新获取当前日期的数据以刷新界面 [dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
const date = dayjs(recordTime).format('YYYY-MM-DD'); );
await getWaterRecordsByDate(date);
// 如果是今天的数据更新Widget
if (date === dayjs().format('YYYY-MM-DD')) {
const todayRecords = waterRecords[date] || [];
const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0);
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false;
}
}, [getWaterRecordsByDate, waterRecords, dailyWaterGoal]);
// 更新喝水记录HealthKit不支持更新只能删除后重新添加 // 更新喝水记录HealthKit不支持更新只能删除后重新添加
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
@@ -524,44 +560,51 @@ export const useWaterDataByDate = (targetDate?: string) => {
}, []); }, []);
// 创建喝水记录 // 创建喝水记录
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
try {
const recordTime = recordedAt || dayjs().toISOString();
// 保存到 HealthKit const addWaterRecord = useCallback(
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime); async (amount: number, recordedAt?: string) => {
if (!healthKitSuccess) { try {
Toast.error('保存到 HealthKit 失败'); const recordTime = recordedAt || dayjs().toISOString();
// 保存到 HealthKit
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
if (!healthKitSuccess) {
Toast.error('保存到 HealthKit 失败');
return false;
}
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(dateToUse);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
await reportWaterChallengeProgress(totalAmount);
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false; return false;
} }
},
// 重新获取当前日期的数据以刷新界面 [dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
await getWaterRecordsByDate(dateToUse); );
// 如果是今天的数据更新Widget
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
const totalAmount = waterRecords.reduce((sum, record) => sum + record.amount, 0) + amount;
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false;
}
}, [getWaterRecordsByDate, dateToUse, waterRecords, dailyWaterGoal]);
// 更新喝水记录 // 更新喝水记录
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {

View File

@@ -25,7 +25,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.14</string> <string>1.0.15</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -1,10 +1,14 @@
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 { getWaterIntakeFromHealthKit } from '@/utils/health';
import { getWaterGoalFromStorage } from '@/utils/userPreferences';
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';
import dayjs from 'dayjs';
export const BACKGROUND_TASK_IDENTIFIER = 'com.anonymous.digitalpilates.task'; export const BACKGROUND_TASK_IDENTIFIER = 'com.anonymous.digitalpilates.task';
@@ -32,8 +36,13 @@ async function executeWaterReminderTask(): Promise<void> {
const waterStats = state.water.todayStats; const waterStats = state.water.todayStats;
const userProfile = state.user.profile; const userProfile = state.user.profile;
// 检查是否有喝水目标设置 // 优先使用 Redux 中的目标,若无则读取本地存储
if (!waterStats || !waterStats.dailyGoal || waterStats.dailyGoal <= 0) { let dailyGoal = waterStats?.dailyGoal ?? 0;
if (!dailyGoal || dailyGoal <= 0) {
dailyGoal = await getWaterGoalFromStorage();
}
if (!dailyGoal || dailyGoal <= 0) {
console.log('没有设置喝水目标,跳过喝水提醒'); console.log('没有设置喝水目标,跳过喝水提醒');
return; return;
} }
@@ -44,11 +53,38 @@ async function executeWaterReminderTask(): Promise<void> {
// 获取用户名 // 获取用户名
const userName = userProfile?.name || '朋友'; const userName = userProfile?.name || '朋友';
const todayRange = {
startDate: dayjs().startOf('day').toDate().toISOString(),
endDate: dayjs().endOf('day').toDate().toISOString()
};
let totalAmount = waterStats?.totalAmount ?? 0;
let completionRate = waterStats?.completionRate ?? (dailyGoal > 0 ? (totalAmount / dailyGoal) * 100 : 0);
try {
const healthKitRecords = await getWaterIntakeFromHealthKit(todayRange);
if (Array.isArray(healthKitRecords) && healthKitRecords.length > 0) {
totalAmount = healthKitRecords.reduce((sum: number, record: unknown) => {
if (record && typeof record === 'object' && 'value' in record) {
const { value } = record as { value?: number | string };
const numericValue = Number(value ?? 0);
return Number.isFinite(numericValue) ? sum + numericValue : sum;
}
return sum;
}, 0);
completionRate = Math.min((totalAmount / dailyGoal) * 100, 100);
} else {
console.log('HealthKit 未返回今日饮水记录,使用应用内缓存数据');
}
} catch (healthKitError) {
console.error('从HealthKit获取饮水记录失败使用应用内缓存数据:', healthKitError);
}
// 构造今日统计数据 // 构造今日统计数据
const todayWaterStats = { const todayWaterStats = {
totalAmount: waterStats.totalAmount || 0, totalAmount,
dailyGoal: waterStats.dailyGoal, dailyGoal,
completionRate: waterStats.completionRate || 0 completionRate: Number.isFinite(completionRate) ? completionRate : 0
}; };
// 调用喝水通知检查函数 // 调用喝水通知检查函数
@@ -99,6 +135,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 +234,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);

103
services/challengesApi.ts Normal file
View File

@@ -0,0 +1,103 @@
import { api } from './api';
export type ChallengeStatus = 'upcoming' | 'ongoing' | 'expired';
export type ChallengeProgressDto = {
completed: number;
target: number;
remaining: number
checkedInToday: boolean;
};
export type RankingItemDto = {
id: string;
name: string;
avatar: string | null;
metric: string;
badge?: string;
todayReportedValue?: number;
todayTargetValue?: number;
};
export enum ChallengeType {
WATER = 'water',
EXERCISE = 'exercise',
DIET = 'diet',
MOOD = 'mood',
SLEEP = 'sleep',
WEIGHT = 'weight',
}
export type ChallengeListItemDto = {
id: string;
title: string;
image: string;
periodLabel?: string;
durationLabel: string;
requirementLabel: string;
unit?: string;
status: ChallengeStatus;
participantsCount: number;
rankingDescription?: string;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
progress?: ChallengeProgressDto;
isJoined: boolean;
startAt?: string;
endAt?: string;
minimumCheckInDays: number; // 最小打卡天数
type: ChallengeType;
};
export type ChallengeDetailDto = ChallengeListItemDto & {
summary?: string;
rankings: RankingItemDto[];
userRank?: number;
};
export type ChallengeRankingsDto = {
total: number;
page: number;
pageSize: number;
items: RankingItemDto[];
};
export async function listChallenges(): Promise<ChallengeListItemDto[]> {
return api.get<ChallengeListItemDto[]>('/challenges');
}
export async function getChallengeDetail(id: string): Promise<ChallengeDetailDto> {
return api.get<ChallengeDetailDto>(`/challenges/${encodeURIComponent(id)}`);
}
export async function joinChallenge(id: string): Promise<ChallengeProgressDto> {
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/join`);
}
export async function leaveChallenge(id: string): Promise<boolean> {
return api.post<boolean>(`/challenges/${encodeURIComponent(id)}/leave`);
}
export async function reportChallengeProgress(id: string, value?: number): Promise<ChallengeProgressDto> {
const body = value != null ? { value } : undefined;
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body);
}
export async function getChallengeRankings(
id: string,
params?: { page?: number; pageSize?: number }
): Promise<ChallengeRankingsDto> {
const searchParams = new URLSearchParams();
if (params?.page) {
searchParams.append('page', String(params.page));
}
if (params?.pageSize) {
searchParams.append('pageSize', String(params.pageSize));
}
const query = searchParams.toString();
const url = `/challenges/${encodeURIComponent(id)}/rankings${query ? `?${query}` : ''}`;
return api.get<ChallengeRankingsDto>(url);
}

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,129 +0,0 @@
import { buildDefaultCustomFromPlan, DayPlan, ExerciseCustomConfig, generatePilates30DayPlan, PilatesLevel } from '@/utils/pilatesPlan';
import AsyncStorage from '@/utils/kvStore';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type DayStatus = 'locked' | 'available' | 'completed';
export type ChallengeDayState = {
plan: DayPlan;
status: DayStatus;
completedAt?: string | null; // ISO
notes?: string;
custom?: ExerciseCustomConfig[]; // 用户自定义:启用/禁用、组数、时长
};
export type ChallengeState = {
startedAt?: string | null;
level: PilatesLevel;
days: ChallengeDayState[]; // 1..30
streak: number; // 连续天数
};
const STORAGE_KEY = '@pilates_challenge_30d';
const initialState: ChallengeState = {
startedAt: null,
level: 'beginner',
days: [],
streak: 0,
};
function computeStreak(days: ChallengeDayState[]): number {
// 连续从第1天开始的已完成天数
let s = 0;
for (let i = 0; i < days.length; i += 1) {
if (days[i].status === 'completed') s += 1; else break;
}
return s;
}
export const initChallenge = createAsyncThunk(
'challenge/init',
async (_: void, { getState }) => {
const persisted = await AsyncStorage.getItem(STORAGE_KEY);
if (persisted) {
try {
const parsed = JSON.parse(persisted) as ChallengeState;
return parsed;
} catch {}
}
// 默认生成
const level: PilatesLevel = 'beginner';
const plans = generatePilates30DayPlan(level);
const days: ChallengeDayState[] = plans.map((p, idx) => ({
plan: p,
status: idx === 0 ? 'available' : 'locked',
custom: buildDefaultCustomFromPlan(p),
}));
const state: ChallengeState = {
startedAt: new Date().toISOString(),
level,
days,
streak: 0,
};
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return state;
}
);
export const persistChallenge = createAsyncThunk(
'challenge/persist',
async (_: void, { getState }) => {
const s = (getState() as any).challenge as ChallengeState;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(s));
return true;
}
);
export const completeDay = createAsyncThunk(
'challenge/completeDay',
async (dayNumber: number, { getState, dispatch }) => {
const state = (getState() as any).challenge as ChallengeState;
const idx = dayNumber - 1;
const days = [...state.days];
if (!days[idx] || days[idx].status === 'completed') return state;
days[idx] = { ...days[idx], status: 'completed', completedAt: new Date().toISOString() };
if (days[idx + 1]) {
days[idx + 1] = { ...days[idx + 1], status: 'available' };
}
const next: ChallengeState = {
...state,
days,
streak: computeStreak(days),
};
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return next;
}
);
const challengeSlice = createSlice({
name: 'challenge',
initialState,
reducers: {
setLevel(state, action: PayloadAction<PilatesLevel>) {
state.level = action.payload;
},
setNote(state, action: PayloadAction<{ dayNumber: number; notes: string }>) {
const idx = action.payload.dayNumber - 1;
if (state.days[idx]) state.days[idx].notes = action.payload.notes;
},
setCustom(state, action: PayloadAction<{ dayNumber: number; custom: ExerciseCustomConfig[] }>) {
const idx = action.payload.dayNumber - 1;
if (state.days[idx]) state.days[idx].custom = action.payload.custom;
},
},
extraReducers: (builder) => {
builder
.addCase(initChallenge.fulfilled, (_state, action) => {
return action.payload as ChallengeState;
})
.addCase(completeDay.fulfilled, (_state, action) => {
return action.payload as ChallengeState;
});
},
});
export const { setLevel, setNote, setCustom } = challengeSlice.actions;
export default challengeSlice.reducer;

454
store/challengesSlice.ts Normal file
View File

@@ -0,0 +1,454 @@
import {
type ChallengeDetailDto,
type ChallengeListItemDto,
type ChallengeProgressDto,
type ChallengeStatus,
type RankingItemDto,
getChallengeDetail,
getChallengeRankings,
joinChallenge as joinChallengeApi,
leaveChallenge as leaveChallengeApi,
listChallenges,
reportChallengeProgress as reportChallengeProgressApi,
} from '@/services/challengesApi';
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
import type { RootState } from './index';
type AsyncStatus = 'idle' | 'loading' | 'succeeded' | 'failed';
export type ChallengeProgress = ChallengeProgressDto;
export type RankingItem = RankingItemDto;
export type ChallengeSummary = ChallengeListItemDto;
export type ChallengeDetail = ChallengeDetailDto;
export type { ChallengeStatus };
export type ChallengeEntity = ChallengeSummary & {
summary?: string;
rankings?: RankingItem[];
userRank?: number;
};
type ChallengeRankingList = {
items: RankingItem[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
};
type ChallengesState = {
entities: Record<string, ChallengeEntity>;
order: string[];
listStatus: AsyncStatus;
listError?: string;
detailStatus: Record<string, AsyncStatus>;
detailError: Record<string, string | undefined>;
joinStatus: Record<string, AsyncStatus>;
joinError: Record<string, string | undefined>;
leaveStatus: Record<string, AsyncStatus>;
leaveError: Record<string, string | undefined>;
progressStatus: Record<string, AsyncStatus>;
progressError: Record<string, string | undefined>;
rankingList: Record<string, ChallengeRankingList | undefined>;
rankingStatus: Record<string, AsyncStatus>;
rankingLoadMoreStatus: Record<string, AsyncStatus>;
rankingError: Record<string, string | undefined>;
};
const initialState: ChallengesState = {
entities: {},
order: [],
listStatus: 'idle',
listError: undefined,
detailStatus: {},
detailError: {},
joinStatus: {},
joinError: {},
leaveStatus: {},
leaveError: {},
progressStatus: {},
progressError: {},
rankingList: {},
rankingStatus: {},
rankingLoadMoreStatus: {},
rankingError: {},
};
const toErrorMessage = (error: unknown): string => {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error && error.message) {
return error.message;
}
return '请求失败,请稍后再试';
};
export const fetchChallenges = createAsyncThunk<ChallengeSummary[], void, { rejectValue: string }>(
'challenges/fetchAll',
async (_, { rejectWithValue }) => {
try {
return await listChallenges();
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
}
);
export const fetchChallengeDetail = createAsyncThunk<ChallengeDetail, string, { rejectValue: string }>(
'challenges/fetchDetail',
async (id, { rejectWithValue }) => {
try {
const ret = await getChallengeDetail(id);
return ret;
} catch (error) {
console.log('######', error);
return rejectWithValue(toErrorMessage(error));
}
}
);
export const joinChallenge = createAsyncThunk<{ id: string; progress: ChallengeProgress }, string, { rejectValue: string }>(
'challenges/join',
async (id, { rejectWithValue }) => {
try {
const progress = await joinChallengeApi(id);
return { id, progress };
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
}
);
export const leaveChallenge = createAsyncThunk<{ id: string }, string, { rejectValue: string }>(
'challenges/leave',
async (id, { rejectWithValue }) => {
try {
await leaveChallengeApi(id);
return { id };
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
}
);
export const reportChallengeProgress = createAsyncThunk<
{ id: string; progress: ChallengeProgress },
{ id: string; value?: number },
{ rejectValue: string }
>('challenges/reportProgress', async ({ id, value }, { rejectWithValue }) => {
try {
const progress = await reportChallengeProgressApi(id, value);
return { id, progress };
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
});
export const fetchChallengeRankings = createAsyncThunk<
{ id: string; total: number; page: number; pageSize: number; items: RankingItem[] },
{ id: string; page?: number; pageSize?: number },
{ rejectValue: string }
>('challenges/fetchRankings', async ({ id, page = 1, pageSize = 20 }, { rejectWithValue }) => {
try {
const data = await getChallengeRankings(id, { page, pageSize });
return { id, ...data };
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
});
const challengesSlice = createSlice({
name: 'challenges',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchChallenges.pending, (state) => {
state.listStatus = 'loading';
state.listError = undefined;
})
.addCase(fetchChallenges.fulfilled, (state, action) => {
state.listStatus = 'succeeded';
state.listError = undefined;
const ids = new Set<string>();
action.payload.forEach((challenge) => {
ids.add(challenge.id);
const existing = state.entities[challenge.id];
if (existing) {
Object.assign(existing, challenge);
} else {
state.entities[challenge.id] = { ...challenge };
}
});
Object.keys(state.entities).forEach((id) => {
if (!ids.has(id)) {
delete state.entities[id];
delete state.detailStatus[id];
delete state.detailError[id];
delete state.joinStatus[id];
delete state.joinError[id];
delete state.leaveStatus[id];
delete state.leaveError[id];
delete state.progressStatus[id];
delete state.progressError[id];
}
});
state.order = action.payload.map((item) => item.id);
})
.addCase(fetchChallenges.rejected, (state, action) => {
state.listStatus = 'failed';
state.listError = action.payload ?? toErrorMessage(action.error);
})
.addCase(fetchChallengeDetail.pending, (state, action) => {
const id = action.meta.arg;
state.detailStatus[id] = 'loading';
state.detailError[id] = undefined;
})
.addCase(fetchChallengeDetail.fulfilled, (state, action) => {
const detail = action.payload;
state.detailStatus[detail.id] = 'succeeded';
state.detailError[detail.id] = undefined;
const existing = state.entities[detail.id];
if (existing) {
Object.assign(existing, detail);
} else {
state.entities[detail.id] = { ...detail };
}
})
.addCase(fetchChallengeDetail.rejected, (state, action) => {
const id = action.meta.arg;
state.detailStatus[id] = 'failed';
state.detailError[id] = action.payload ?? toErrorMessage(action.error);
})
.addCase(joinChallenge.pending, (state, action) => {
const id = action.meta.arg;
state.joinStatus[id] = 'loading';
state.joinError[id] = undefined;
})
.addCase(joinChallenge.fulfilled, (state, action) => {
const { id, progress } = action.payload;
state.joinStatus[id] = 'succeeded';
state.joinError[id] = undefined;
const entity = state.entities[id];
if (entity) {
entity.isJoined = true;
entity.progress = progress;
}
})
.addCase(joinChallenge.rejected, (state, action) => {
const id = action.meta.arg;
state.joinStatus[id] = 'failed';
state.joinError[id] = action.payload ?? toErrorMessage(action.error);
})
.addCase(leaveChallenge.pending, (state, action) => {
const id = action.meta.arg;
state.leaveStatus[id] = 'loading';
state.leaveError[id] = undefined;
})
.addCase(leaveChallenge.fulfilled, (state, action) => {
const { id } = action.payload;
state.leaveStatus[id] = 'succeeded';
state.leaveError[id] = undefined;
const entity = state.entities[id];
if (entity) {
entity.isJoined = false;
delete entity.progress;
}
})
.addCase(leaveChallenge.rejected, (state, action) => {
const id = action.meta.arg;
state.leaveStatus[id] = 'failed';
state.leaveError[id] = action.payload ?? toErrorMessage(action.error);
})
.addCase(reportChallengeProgress.pending, (state, action) => {
const id = action.meta.arg.id;
state.progressStatus[id] = 'loading';
state.progressError[id] = undefined;
})
.addCase(reportChallengeProgress.fulfilled, (state, action) => {
const { id, progress } = action.payload;
state.progressStatus[id] = 'succeeded';
state.progressError[id] = undefined;
const entity = state.entities[id];
if (entity) {
entity.progress = progress;
}
})
.addCase(reportChallengeProgress.rejected, (state, action) => {
const id = action.meta.arg.id;
state.progressStatus[id] = 'failed';
state.progressError[id] = action.payload ?? toErrorMessage(action.error);
})
.addCase(fetchChallengeRankings.pending, (state, action) => {
const { id, page = 1 } = action.meta.arg;
if (page <= 1) {
state.rankingStatus[id] = 'loading';
state.rankingError[id] = undefined;
state.rankingLoadMoreStatus[id] = 'idle';
} else {
state.rankingLoadMoreStatus[id] = 'loading';
}
})
.addCase(fetchChallengeRankings.fulfilled, (state, action) => {
const { id, items, page, pageSize, total } = action.payload;
const existing = state.rankingList[id];
let merged: RankingItem[];
if (!existing || page <= 1) {
merged = [...items];
} else {
const map = new Map(existing.items.map((item) => [item.id, item] as const));
items.forEach((item) => {
map.set(item.id, item);
});
merged = Array.from(map.values());
}
const hasMore = merged.length < total;
state.rankingList[id] = { items: merged, total, page, pageSize, hasMore };
if (page <= 1) {
state.rankingStatus[id] = 'succeeded';
state.rankingError[id] = undefined;
} else {
state.rankingLoadMoreStatus[id] = 'succeeded';
}
})
.addCase(fetchChallengeRankings.rejected, (state, action) => {
const { id, page = 1 } = action.meta.arg;
const message = action.payload ?? toErrorMessage(action.error);
if (page <= 1) {
state.rankingStatus[id] = 'failed';
state.rankingError[id] = message;
} else {
state.rankingLoadMoreStatus[id] = 'failed';
state.rankingError[id] = message;
}
});
},
});
export default challengesSlice.reducer;
const selectChallengesState = (state: RootState) => state.challenges;
export const selectChallengesListStatus = createSelector(
[selectChallengesState],
(state) => state.listStatus
);
export const selectChallengesListError = createSelector(
[selectChallengesState],
(state) => state.listError
);
export const selectChallengeEntities = createSelector(
[selectChallengesState],
(state) => state.entities
);
export const selectChallengeOrder = createSelector(
[selectChallengesState],
(state) => state.order
);
export const selectChallengeList = createSelector(
[selectChallengeEntities, selectChallengeOrder],
(entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
);
const formatNumberWithSeparator = (value: number): string =>
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const formatMonthDay = (input: string | undefined): string | undefined => {
if (!input) return undefined;
const date = new Date(input);
if (Number.isNaN(date.getTime())) return undefined;
return `${date.getMonth() + 1}${date.getDate()}`;
};
const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
const startLabel = formatMonthDay(challenge.startAt);
const endLabel = formatMonthDay(challenge.endAt);
if (startLabel && endLabel) {
return `${startLabel} - ${endLabel}`;
}
return challenge.periodLabel ?? challenge.durationLabel;
};
export type ChallengeCardViewModel = {
id: string;
title: string;
image: string;
dateRange: string;
participantsLabel: string;
status: ChallengeStatus;
isJoined: boolean;
endAt?: string;
periodLabel?: string;
durationLabel: string;
requirementLabel: string;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
progress?: ChallengeProgress;
avatars: string[];
};
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
challenges.map<ChallengeCardViewModel>((challenge) => ({
id: challenge.id,
title: challenge.title,
image: challenge.image,
dateRange: buildDateRangeLabel(challenge),
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
status: challenge.status,
isJoined: challenge.isJoined,
endAt: challenge.endAt,
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
highlightTitle: challenge.highlightTitle,
highlightSubtitle: challenge.highlightSubtitle,
ctaLabel: challenge.ctaLabel,
progress: challenge.progress,
avatars: [],
}))
);
export const selectChallengeById = (id: string) =>
createSelector([selectChallengeEntities], (entities) => entities[id]);
export const selectChallengeDetailStatus = (id: string) =>
createSelector([selectChallengesState], (state) => state.detailStatus[id] ?? 'idle');
export const selectChallengeDetailError = (id: string) =>
createSelector([selectChallengesState], (state) => state.detailError[id]);
export const selectJoinStatus = (id: string) =>
createSelector([selectChallengesState], (state) => state.joinStatus[id] ?? 'idle');
export const selectJoinError = (id: string) =>
createSelector([selectChallengesState], (state) => state.joinError[id]);
export const selectLeaveStatus = (id: string) =>
createSelector([selectChallengesState], (state) => state.leaveStatus[id] ?? 'idle');
export const selectLeaveError = (id: string) =>
createSelector([selectChallengesState], (state) => state.leaveError[id]);
export const selectProgressStatus = (id: string) =>
createSelector([selectChallengesState], (state) => state.progressStatus[id] ?? 'idle');
export const selectProgressError = (id: string) =>
createSelector([selectChallengesState], (state) => state.progressError[id]);
export const selectChallengeRankingList = (id: string) =>
createSelector([selectChallengesState], (state) => state.rankingList[id]);
export const selectChallengeRankingStatus = (id: string) =>
createSelector([selectChallengesState], (state) => state.rankingStatus[id] ?? 'idle');
export const selectChallengeRankingLoadMoreStatus = (id: string) =>
createSelector([selectChallengesState], (state) => state.rankingLoadMoreStatus[id] ?? 'idle');
export const selectChallengeRankingError = (id: string) =>
createSelector([selectChallengesState], (state) => state.rankingError[id]);

View File

@@ -1,5 +1,5 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import challengeReducer from './challengeSlice'; import challengesReducer from './challengesSlice';
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
import circumferenceReducer from './circumferenceSlice'; import circumferenceReducer from './circumferenceSlice';
import exerciseLibraryReducer from './exerciseLibrarySlice'; import exerciseLibraryReducer from './exerciseLibrarySlice';
@@ -47,7 +47,7 @@ syncActions.forEach(action => {
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
user: userReducer, user: userReducer,
challenge: challengeReducer, challenges: challengesReducer,
checkin: checkinReducer, checkin: checkinReducer,
circumference: circumferenceReducer, circumference: circumferenceReducer,
goals: goalsReducer, goals: goalsReducer,
@@ -70,4 +70,3 @@ export const store = configureStore({
export type RootState = ReturnType<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch; export type AppDispatch = typeof store.dispatch;

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