231 lines
6.2 KiB
TypeScript
231 lines
6.2 KiB
TypeScript
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useAppSelector } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { selectChallengeCards, type ChallengeViewModel } from '@/store/challengesSlice';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { useRouter } from 'expo-router';
|
||
import React from 'react';
|
||
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
|
||
const AVATAR_SIZE = 36;
|
||
const CARD_IMAGE_WIDTH = 132;
|
||
const CARD_IMAGE_HEIGHT = 96;
|
||
|
||
export default function ChallengesScreen() {
|
||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||
const colorTokens = Colors[theme];
|
||
const router = useRouter();
|
||
const challenges = useAppSelector(selectChallengeCards);
|
||
|
||
const gradientColors: [string, string] =
|
||
theme === 'dark'
|
||
? ['#1f2230', '#10131e']
|
||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||
|
||
return (
|
||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
|
||
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||
<ScrollView
|
||
contentContainerStyle={styles.scrollContent}
|
||
showsVerticalScrollIndicator={false}
|
||
bounces
|
||
>
|
||
<View style={styles.headerRow}>
|
||
<View>
|
||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||
</View>
|
||
<TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||
<LinearGradient
|
||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
style={styles.giftButton}
|
||
>
|
||
<IconSymbol name="gift.fill" size={22} color={colorTokens.onPrimary} />
|
||
</LinearGradient>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<View style={styles.cardsContainer}>
|
||
{challenges.map((challenge) => (
|
||
<ChallengeCard
|
||
key={challenge.id}
|
||
challenge={challenge}
|
||
surfaceColor={colorTokens.surface}
|
||
textColor={colorTokens.text}
|
||
mutedColor={colorTokens.textSecondary}
|
||
onPress={() =>
|
||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||
}
|
||
/>
|
||
))}
|
||
</View>
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
type ChallengeCardProps = {
|
||
challenge: ChallengeViewModel;
|
||
surfaceColor: string;
|
||
textColor: string;
|
||
mutedColor: string;
|
||
onPress: () => void;
|
||
};
|
||
|
||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||
return (
|
||
<TouchableOpacity
|
||
activeOpacity={0.92}
|
||
onPress={onPress}
|
||
style={[
|
||
styles.card,
|
||
{
|
||
backgroundColor: surfaceColor,
|
||
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
||
},
|
||
]}
|
||
>
|
||
<Image
|
||
source={{ uri: challenge.image }}
|
||
style={styles.cardImage}
|
||
resizeMode="cover"
|
||
/>
|
||
|
||
<View style={styles.cardContent}>
|
||
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}>
|
||
{challenge.title}
|
||
</Text>
|
||
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
|
||
<Text style={[styles.cardParticipants, { color: mutedColor }]}>{challenge.participantsLabel}</Text>
|
||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
type AvatarStackProps = {
|
||
avatars: string[];
|
||
borderColor: string;
|
||
};
|
||
|
||
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
||
return (
|
||
<View style={styles.avatarRow}>
|
||
{avatars.map((avatar, index) => (
|
||
<Image
|
||
key={`${avatar}-${index}`}
|
||
source={{ uri: avatar }}
|
||
style={[
|
||
styles.avatar,
|
||
{ borderColor },
|
||
index === 0 ? null : styles.avatarOffset,
|
||
]}
|
||
/>
|
||
))}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: {
|
||
flex: 1,
|
||
},
|
||
safeArea: {
|
||
flex: 1,
|
||
},
|
||
scrollContent: {
|
||
paddingHorizontal: 20,
|
||
paddingBottom: 32,
|
||
},
|
||
headerRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginTop: 8,
|
||
marginBottom: 26,
|
||
},
|
||
title: {
|
||
fontSize: 32,
|
||
fontWeight: '700',
|
||
letterSpacing: 1,
|
||
},
|
||
subtitle: {
|
||
marginTop: 6,
|
||
fontSize: 14,
|
||
fontWeight: '500',
|
||
opacity: 0.8,
|
||
},
|
||
giftShadow: {
|
||
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
||
shadowOffset: { width: 0, height: 8 },
|
||
shadowOpacity: 0.35,
|
||
shadowRadius: 12,
|
||
elevation: 8,
|
||
borderRadius: 26,
|
||
},
|
||
giftButton: {
|
||
width: 52,
|
||
height: 52,
|
||
borderRadius: 26,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
cardsContainer: {
|
||
gap: 18,
|
||
},
|
||
card: {
|
||
flexDirection: 'row',
|
||
borderRadius: 28,
|
||
padding: 18,
|
||
alignItems: 'center',
|
||
shadowOffset: { width: 0, height: 16 },
|
||
shadowOpacity: 0.18,
|
||
shadowRadius: 24,
|
||
elevation: 6,
|
||
},
|
||
cardImage: {
|
||
width: CARD_IMAGE_WIDTH,
|
||
height: CARD_IMAGE_HEIGHT,
|
||
borderRadius: 22,
|
||
},
|
||
cardContent: {
|
||
flex: 1,
|
||
marginLeft: 16,
|
||
},
|
||
cardTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
marginBottom: 4,
|
||
},
|
||
cardDate: {
|
||
fontSize: 13,
|
||
fontWeight: '500',
|
||
marginBottom: 4,
|
||
},
|
||
cardParticipants: {
|
||
fontSize: 13,
|
||
fontWeight: '500',
|
||
},
|
||
avatarRow: {
|
||
flexDirection: 'row',
|
||
marginTop: 16,
|
||
alignItems: 'center',
|
||
},
|
||
avatar: {
|
||
width: AVATAR_SIZE,
|
||
height: AVATAR_SIZE,
|
||
borderRadius: AVATAR_SIZE / 2,
|
||
borderWidth: 2,
|
||
},
|
||
avatarOffset: {
|
||
marginLeft: -12,
|
||
},
|
||
});
|