Compare commits
12 Commits
feature/ri
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47c8bfc5bc | ||
|
|
3e6f55d804 | ||
|
|
b0602b0a99 | ||
|
|
d32a822604 | ||
|
|
8f847465ef | ||
|
|
d74bd214ed | ||
|
|
970a4b8568 | ||
|
|
9c86b0e565 | ||
|
|
31c4e4fafa | ||
|
|
b80af23f4f | ||
|
|
7259bd7a2c | ||
|
|
2b86ac17a6 |
2
app.json
2
app.json
@@ -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",
|
||||||
|
|||||||
@@ -1,125 +1,123 @@
|
|||||||
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 (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
<View style={styles.stateContainer}>
|
||||||
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
||||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
bounces
|
|
||||||
>
|
|
||||||
<View style={styles.headerRow}>
|
|
||||||
<View>
|
|
||||||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
|
||||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
);
|
||||||
<LinearGradient
|
}
|
||||||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
|
||||||
start={{ x: 0, y: 0 }}
|
if (listStatus === 'failed' && challenges.length === 0) {
|
||||||
end={{ x: 1, y: 1 }}
|
return (
|
||||||
style={styles.giftButton}
|
<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())}
|
||||||
>
|
>
|
||||||
<IconSymbol name="gift.fill" size={22} color={colorTokens.onPrimary} />
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<View style={styles.cardsContainer}>
|
if (challenges.length === 0) {
|
||||||
{CHALLENGES.map((challenge) => (
|
return (
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return challenges.map((challenge) => (
|
||||||
<ChallengeCard
|
<ChallengeCard
|
||||||
key={challenge.id}
|
key={challenge.id}
|
||||||
challenge={challenge}
|
challenge={challenge}
|
||||||
@@ -130,16 +128,56 @@ export default function ChallengesScreen() {
|
|||||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: insets.top,
|
||||||
|
}]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
bounces
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.giftButton}
|
||||||
|
>
|
||||||
|
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity> */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{ongoingChallenges.length ? (
|
||||||
|
<OngoingChallengesCarousel
|
||||||
|
challenges={ongoingChallenges}
|
||||||
|
colorTokens={colorTokens}
|
||||||
|
trackColor={progressTrackColor}
|
||||||
|
inactiveColor={progressInactiveColor}
|
||||||
|
onPress={(challenge) =>
|
||||||
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.cardsContainer}>{renderChallenges()}</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,24 +199,210 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
<View style={styles.cardInner}>
|
||||||
|
<View style={styles.cardMedia}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: challenge.image }}
|
source={{ uri: challenge.image }}
|
||||||
style={styles.cardImage}
|
style={styles.cardImage}
|
||||||
resizeMode="cover"
|
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}>
|
<View style={styles.cardContent}>
|
||||||
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}>
|
<Text
|
||||||
|
style={[styles.cardTitle, { color: textColor }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{challenge.title}
|
{challenge.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
|
<Text
|
||||||
<Text style={[styles.cardParticipants, { color: mutedColor }]}>{challenge.participantsLabel}</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} />
|
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
</TouchableOpacity>
|
</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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{challenges.length > 1 ? (
|
||||||
|
<View style={styles.carouselIndicators}>
|
||||||
|
{challenges.map((challenge, index) => {
|
||||||
|
const inputRange = [
|
||||||
|
(index - 1) * snapInterval,
|
||||||
|
index * snapInterval,
|
||||||
|
(index + 1) * snapInterval,
|
||||||
|
];
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type AvatarStackProps = {
|
type AvatarStackProps = {
|
||||||
avatars: string[];
|
avatars: string[];
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
@@ -185,7 +411,9 @@ 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
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((avatar, index) => (
|
||||||
<Image
|
<Image
|
||||||
key={`${avatar}-${index}`}
|
key={`${avatar}-${index}`}
|
||||||
source={{ uri: avatar }}
|
source={{ uri: avatar }}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
822
app/challenges/[id]/index.tsx
Normal file
822
app/challenges/[id]/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
298
app/challenges/[id]/leaderboard.tsx
Normal file
298
app/challenges/[id]/leaderboard.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
262
components/challenges/ChallengeProgressCard.tsx
Normal file
262
components/challenges/ChallengeProgressCard.tsx
Normal 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;
|
||||||
143
components/challenges/ChallengeRankingItem.tsx
Normal file
143
components/challenges/ChallengeRankingItem.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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: {
|
||||||
// 基础文本/背景
|
// 基础文本/背景
|
||||||
|
|||||||
@@ -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,9 +181,14 @@ export const useWaterData = () => {
|
|||||||
}, [getWaterRecordsByDate]);
|
}, [getWaterRecordsByDate]);
|
||||||
|
|
||||||
// 创建喝水记录
|
// 创建喝水记录
|
||||||
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => {
|
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
||||||
|
|
||||||
|
const addWaterRecord = useCallback(
|
||||||
|
async (amount: number, recordedAt?: string) => {
|
||||||
try {
|
try {
|
||||||
const recordTime = recordedAt || dayjs().toISOString();
|
const recordTime = recordedAt || dayjs().toISOString();
|
||||||
|
const date = dayjs(recordTime).format('YYYY-MM-DD');
|
||||||
|
const isToday = dayjs(recordTime).isSame(dayjs(), 'day');
|
||||||
|
|
||||||
// 保存到 HealthKit
|
// 保存到 HealthKit
|
||||||
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
||||||
@@ -164,13 +198,11 @@ export const useWaterData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 重新获取当前日期的数据以刷新界面
|
// 重新获取当前日期的数据以刷新界面
|
||||||
const date = dayjs(recordTime).format('YYYY-MM-DD');
|
const updatedRecords = await getWaterRecordsByDate(date);
|
||||||
await getWaterRecordsByDate(date);
|
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
||||||
|
|
||||||
// 如果是今天的数据,更新Widget
|
// 如果是今天的数据,更新Widget
|
||||||
if (date === dayjs().format('YYYY-MM-DD')) {
|
if (isToday) {
|
||||||
const todayRecords = waterRecords[date] || [];
|
|
||||||
const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0);
|
|
||||||
const quickAddAmount = await getQuickWaterAmount();
|
const quickAddAmount = await getQuickWaterAmount();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -185,13 +217,17 @@ export const useWaterData = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await reportWaterChallengeProgress(totalAmount);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('添加喝水记录失败:', error);
|
console.error('添加喝水记录失败:', error);
|
||||||
Toast.error(error?.message || '添加喝水记录失败');
|
Toast.error(error?.message || '添加喝水记录失败');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [getWaterRecordsByDate, waterRecords, dailyWaterGoal]);
|
},
|
||||||
|
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
|
||||||
|
);
|
||||||
|
|
||||||
// 更新喝水记录(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,7 +560,10 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 创建喝水记录
|
// 创建喝水记录
|
||||||
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => {
|
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
||||||
|
|
||||||
|
const addWaterRecord = useCallback(
|
||||||
|
async (amount: number, recordedAt?: string) => {
|
||||||
try {
|
try {
|
||||||
const recordTime = recordedAt || dayjs().toISOString();
|
const recordTime = recordedAt || dayjs().toISOString();
|
||||||
|
|
||||||
@@ -536,11 +575,11 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 重新获取当前日期的数据以刷新界面
|
// 重新获取当前日期的数据以刷新界面
|
||||||
await getWaterRecordsByDate(dateToUse);
|
const updatedRecords = await getWaterRecordsByDate(dateToUse);
|
||||||
|
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
||||||
|
|
||||||
// 如果是今天的数据,更新Widget
|
// 如果是今天的数据,更新Widget
|
||||||
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
|
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
|
||||||
const totalAmount = waterRecords.reduce((sum, record) => sum + record.amount, 0) + amount;
|
|
||||||
const quickAddAmount = await getQuickWaterAmount();
|
const quickAddAmount = await getQuickWaterAmount();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -555,13 +594,17 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await reportWaterChallengeProgress(totalAmount);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('添加喝水记录失败:', error);
|
console.error('添加喝水记录失败:', error);
|
||||||
Toast.error(error?.message || '添加喝水记录失败');
|
Toast.error(error?.message || '添加喝水记录失败');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [getWaterRecordsByDate, dateToUse, waterRecords, dailyWaterGoal]);
|
},
|
||||||
|
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
|
||||||
|
);
|
||||||
|
|
||||||
// 更新喝水记录
|
// 更新喝水记录
|
||||||
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
|
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
103
services/challengesApi.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -161,6 +161,10 @@ export class NotificationService {
|
|||||||
if (data?.url) {
|
if (data?.url) {
|
||||||
router.push(data.url as any);
|
router.push(data.url as any);
|
||||||
}
|
}
|
||||||
|
} else if (data?.type === NotificationTypes.CHALLENGE_ENCOURAGEMENT) {
|
||||||
|
console.log('用户点击了挑战提醒通知', data);
|
||||||
|
const targetUrl = (data?.url as string) || '/(tabs)/challenges';
|
||||||
|
router.push(targetUrl as any);
|
||||||
} else if (data?.type === 'mood_reminder') {
|
} else if (data?.type === 'mood_reminder') {
|
||||||
// 处理心情提醒通知
|
// 处理心情提醒通知
|
||||||
console.log('用户点击了心情提醒通知', data);
|
console.log('用户点击了心情提醒通知', data);
|
||||||
@@ -506,6 +510,7 @@ export const NotificationTypes = {
|
|||||||
MOOD_REMINDER: 'mood_reminder',
|
MOOD_REMINDER: 'mood_reminder',
|
||||||
WATER_REMINDER: 'water_reminder',
|
WATER_REMINDER: 'water_reminder',
|
||||||
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
||||||
|
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 便捷方法
|
// 便捷方法
|
||||||
|
|||||||
@@ -1,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
454
store/challengesSlice.ts
Normal 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]);
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { NotificationData, notificationService } from '../services/notifications';
|
import { NotificationData, NotificationTypes, notificationService } from '../services/notifications';
|
||||||
import { getNotificationEnabled } from './userPreferences';
|
import { getNotificationEnabled } from './userPreferences';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,6 +287,33 @@ export class GoalNotificationHelpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ChallengeNotificationHelpers {
|
||||||
|
static buildChallengesTabUrl(): string {
|
||||||
|
return '/(tabs)/challenges';
|
||||||
|
}
|
||||||
|
|
||||||
|
static async sendEncouragementNotification(params: {
|
||||||
|
userName: string;
|
||||||
|
challengeTitle: string;
|
||||||
|
challengeId: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { userName, challengeTitle, challengeId } = params;
|
||||||
|
const notification: NotificationData = {
|
||||||
|
title: '挑战提醒',
|
||||||
|
body: `${userName},今天还没有完成「${challengeTitle}」挑战,快来打卡吧!`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.CHALLENGE_ENCOURAGEMENT,
|
||||||
|
challengeId,
|
||||||
|
url: ChallengeNotificationHelpers.buildChallengesTabUrl(),
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
};
|
||||||
|
|
||||||
|
return notificationService.sendImmediateNotification(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 营养相关的通知辅助函数
|
* 营养相关的通知辅助函数
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user