feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等 - 实现挑战邀请码系统,支持通过邀请码加入自定义挑战 - 完善挑战详情页面的多语言翻译支持 - 优化用户认证状态检查逻辑,使用token作为主要判断依据 - 添加阿里字体文件支持,提升UI显示效果 - 改进确认弹窗组件,支持Liquid Glass效果和自定义内容 - 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
This commit is contained in:
@@ -1,21 +1,32 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
fetchChallenges,
|
||||
joinChallengeByCode,
|
||||
resetJoinByCodeState,
|
||||
selectChallengeCards,
|
||||
selectChallengesListError,
|
||||
selectChallengesListStatus,
|
||||
selectCustomChallengeCards,
|
||||
selectJoinByCodeError,
|
||||
selectJoinByCodeStatus,
|
||||
selectOfficialChallengeCards,
|
||||
type ChallengeCardViewModel,
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
@@ -23,6 +34,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions
|
||||
@@ -32,11 +44,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
const AVATAR_SIZE = 36;
|
||||
const CARD_IMAGE_WIDTH = 132;
|
||||
const CARD_IMAGE_HEIGHT = 96;
|
||||
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
||||
upcoming: '即将开始',
|
||||
ongoing: '进行中',
|
||||
expired: '已结束',
|
||||
};
|
||||
|
||||
const CAROUSEL_ITEM_SPACING = 16;
|
||||
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
||||
@@ -45,18 +52,32 @@ const DOT_BASE_SIZE = 6;
|
||||
export default function ChallengesScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
const colorTokens = Colors[theme];
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const challenges = useAppSelector(selectChallengeCards);
|
||||
const glassAvailable = isLiquidGlassAvailable();
|
||||
const allChallenges = useAppSelector(selectChallengeCards);
|
||||
const customChallenges = useAppSelector(selectCustomChallengeCards);
|
||||
|
||||
|
||||
const officialChallenges = useAppSelector(selectOfficialChallengeCards);
|
||||
const joinedCustomChallenges = useMemo(
|
||||
() => customChallenges.filter((item) => item.isJoined),
|
||||
[customChallenges]
|
||||
);
|
||||
const listStatus = useAppSelector(selectChallengesListStatus);
|
||||
const listError = useAppSelector(selectChallengesListError);
|
||||
const joinByCodeStatus = useAppSelector(selectJoinByCodeStatus);
|
||||
const joinByCodeError = useAppSelector(selectJoinByCodeError);
|
||||
const [joinModalVisible, setJoinModalVisible] = useState(false);
|
||||
const [shareCodeInput, setShareCodeInput] = useState('');
|
||||
const ongoingChallenges = useMemo(() => {
|
||||
const now = dayjs();
|
||||
return challenges.filter((challenge) => {
|
||||
return allChallenges.filter((challenge) => {
|
||||
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
||||
return false;
|
||||
}
|
||||
@@ -70,7 +91,7 @@ export default function ChallengesScreen() {
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [challenges]);
|
||||
}, [allChallenges]);
|
||||
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
||||
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
||||
|
||||
@@ -85,53 +106,132 @@ export default function ChallengesScreen() {
|
||||
? ['#1f2230', '#10131e']
|
||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||
|
||||
useEffect(() => {
|
||||
if (!joinModalVisible) {
|
||||
dispatch(resetJoinByCodeState());
|
||||
setShareCodeInput('');
|
||||
}
|
||||
}, [dispatch, joinModalVisible]);
|
||||
|
||||
const handleCreatePress = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
router.push('/challenges/create-custom');
|
||||
}, [ensureLoggedIn, router]);
|
||||
|
||||
const handleOpenJoin = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setJoinModalVisible(true);
|
||||
}, [ensureLoggedIn]);
|
||||
|
||||
const isJoiningByCode = joinByCodeStatus === 'loading';
|
||||
|
||||
const handleSubmitShareCode = useCallback(async () => {
|
||||
if (isJoiningByCode) return;
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
if (!shareCodeInput.trim()) {
|
||||
Toast.warning(t('challenges.invalidInviteCode'));
|
||||
return;
|
||||
}
|
||||
const formatted = shareCodeInput.trim().toUpperCase();
|
||||
try {
|
||||
const result = await dispatch(joinChallengeByCode(formatted)).unwrap();
|
||||
await dispatch(fetchChallenges());
|
||||
setJoinModalVisible(false);
|
||||
Toast.success(t('challenges.joinSuccess'));
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: result.challenge.id } });
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : t('challenges.joinFailed');
|
||||
Toast.error(message);
|
||||
}
|
||||
}, [dispatch, ensureLoggedIn, isJoiningByCode, router, shareCodeInput]);
|
||||
|
||||
const renderChallenges = () => {
|
||||
if (listStatus === 'loading' && challenges.length === 0) {
|
||||
if (listStatus === 'loading' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (listStatus === 'failed' && challenges.length === 0) {
|
||||
if (listStatus === 'failed' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||||
{listError ?? '加载挑战失败,请稍后重试'}
|
||||
{listError ?? t('challenges.loadFailed')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => dispatch(fetchChallenges())}
|
||||
>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (challenges.length === 0) {
|
||||
if (customChallenges.length === 0 && officialChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return challenges.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
surfaceColor={colorTokens.surface}
|
||||
textColor={colorTokens.text}
|
||||
mutedColor={colorTokens.textSecondary}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||
}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<View style={styles.cardGroups}>
|
||||
{joinedCustomChallenges.length ? (
|
||||
<>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.customChallenges')}</Text>
|
||||
</View>
|
||||
<View style={styles.cardsContainer}>
|
||||
{joinedCustomChallenges.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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={[styles.sectionHeaderRow, { marginTop: joinedCustomChallenges.length ? 12 : 0 }]}>
|
||||
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.officialChallengesTitle')}</Text>
|
||||
</View>
|
||||
{officialChallenges.length ? (
|
||||
<View style={styles.cardsContainer}>
|
||||
{officialChallenges.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>
|
||||
) : (
|
||||
<View style={[styles.stateContainer, styles.customEmpty]}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.officialChallenges')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -146,19 +246,42 @@ export default function ChallengesScreen() {
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<View>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>{t('challenges.title')}</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>{t('challenges.subtitle')}</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.joinButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255,255,255,0.18)"
|
||||
isInteractive
|
||||
>
|
||||
<Text style={styles.joinButtonLabel}>{t('challenges.join')}</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
||||
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>{t('challenges.join')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity activeOpacity={0.9} onPress={handleCreatePress} style={{ marginLeft: 10 }}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.createButton}
|
||||
tintColor="rgba(255,255,255,0.22)"
|
||||
isInteractive
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#0f1528" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.createButton, styles.createButtonFallback]}>
|
||||
<Ionicons name="add" size={18} color={colorTokens.text} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</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 ? (
|
||||
@@ -175,6 +298,34 @@ export default function ChallengesScreen() {
|
||||
|
||||
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
||||
</ScrollView>
|
||||
<ConfirmationSheet
|
||||
visible={joinModalVisible}
|
||||
onClose={() => setJoinModalVisible(false)}
|
||||
onConfirm={handleSubmitShareCode}
|
||||
title={t('challenges.joinModal.title')}
|
||||
description={t('challenges.joinModal.description')}
|
||||
confirmText={isJoiningByCode ? t('challenges.joinModal.joining') : t('challenges.joinModal.confirm')}
|
||||
cancelText={t('challenges.joinModal.cancel')}
|
||||
loading={isJoiningByCode}
|
||||
content={
|
||||
<View style={styles.modalInputWrapper}>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
placeholder={t('challenges.joinModal.placeholder')}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={shareCodeInput}
|
||||
onChangeText={(text) => setShareCodeInput(text.toUpperCase())}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
keyboardType="default"
|
||||
maxLength={12}
|
||||
/>
|
||||
{joinByCodeError && joinModalVisible ? (
|
||||
<Text style={styles.modalError}>{joinByCodeError}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -188,7 +339,8 @@ type ChallengeCardProps = {
|
||||
};
|
||||
|
||||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
|
||||
const { t } = useI18n();
|
||||
const statusLabel = t(`challenges.statusLabels.${challenge.status}`) ?? challenge.status;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -238,7 +390,7 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
|
||||
style={[styles.cardParticipants, { color: mutedColor }]}
|
||||
>
|
||||
{challenge.participantsLabel}
|
||||
{challenge.isJoined ? ' · 已加入' : ''}
|
||||
{challenge.isJoined ? ` · ${t('challenges.joined')}` : ''}
|
||||
</Text>
|
||||
{challenge.avatars.length ? (
|
||||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||
@@ -328,7 +480,7 @@ function OngoingChallengesCarousel({
|
||||
>
|
||||
<ChallengeProgressCard
|
||||
title={item.title}
|
||||
endAt={item.endAt}
|
||||
endAt={item.endAt as string}
|
||||
progress={item.progress}
|
||||
style={styles.carouselProgressCard}
|
||||
backgroundColors={[colorTokens.card, colorTokens.card]}
|
||||
@@ -453,31 +605,79 @@ const styles = StyleSheet.create({
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
giftShadow: {
|
||||
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
borderRadius: 26,
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
giftButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 26,
|
||||
joinButtonGlass: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
},
|
||||
joinButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f1528',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
joinButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
createButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||
},
|
||||
createButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
},
|
||||
cardsContainer: {
|
||||
gap: 18,
|
||||
},
|
||||
cardGroups: {
|
||||
gap: 20,
|
||||
},
|
||||
sectionHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionHeaderText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
customEmpty: {
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
primaryGhostButton: {
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: 14,
|
||||
},
|
||||
carouselContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
@@ -558,16 +758,19 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
cardDate: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
cardParticipants: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
cardExpired: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
@@ -597,6 +800,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#f7f9ff',
|
||||
letterSpacing: 0.3,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
cardProgress: {
|
||||
marginTop: 8,
|
||||
@@ -617,4 +821,25 @@ const styles = StyleSheet.create({
|
||||
avatarOffset: {
|
||||
marginLeft: -12,
|
||||
},
|
||||
modalInputWrapper: {
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
gap: 6,
|
||||
},
|
||||
modalInput: {
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.5,
|
||||
color: '#0f1528',
|
||||
},
|
||||
modalError: {
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user