feat(challenges): 添加自定义挑战功能和多语言支持

- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等
- 实现挑战邀请码系统,支持通过邀请码加入自定义挑战
- 完善挑战详情页面的多语言翻译支持
- 优化用户认证状态检查逻辑,使用token作为主要判断依据
- 添加阿里字体文件支持,提升UI显示效果
- 改进确认弹窗组件,支持Liquid Glass效果和自定义内容
- 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
This commit is contained in:
richarjiang
2025-11-26 16:39:01 +08:00
parent 3ad0e08d58
commit 39671ed70f
24 changed files with 3124 additions and 727 deletions

View File

@@ -99,7 +99,7 @@ export default function TabLayout() {
color: colorTokens.tabIconSelected,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
marginLeft: 6
}}
numberOfLines={1}
>

View File

@@ -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',
},
});

View File

@@ -598,6 +598,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliRegular'
},
debugButtonsContainer: {
flexDirection: 'row',

View File

@@ -510,6 +510,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
AliRegular: require('../assets/fonts/ali-regular.ttf'),
AliBold: require('../assets/fonts/ali-bold.ttf'),
});
if (!loaded) {

View File

@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { ChallengeSource } from '@/services/challengesApi';
import {
fetchChallengeDetail,
fetchChallengeRankings,
@@ -23,13 +24,17 @@ import {
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
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, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Alert,
@@ -87,6 +92,7 @@ export default function ChallengeDetailScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { ensureLoggedIn } = useAuthGuard();
@@ -155,6 +161,24 @@ export default function ChallengeDetailScreen() {
}, [showCelebration]);
const progress = challenge?.progress;
const isJoined = challenge?.isJoined ?? false;
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
const lastProgressAt = useMemo(() => {
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
}, [challenge?.progress]);
const hasCheckedInToday = useMemo(() => {
if (!challenge?.progress) {
return false;
}
if (lastProgressAt) {
const lastDate = dayjs(lastProgressAt);
if (lastDate.isValid() && lastDate.isSame(dayjs(), 'day')) {
return true;
}
}
return challenge.progress.checkedInToday ?? false;
}, [challenge?.progress, lastProgressAt]);
const rankingData = useMemo(() => {
const source = rankingList?.items ?? challenge?.rankings ?? [];
@@ -165,6 +189,7 @@ export default function ChallengeDetailScreen() {
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
[rankingData],
);
const showShareCode = isJoined && Boolean(challenge?.shareCode);
const handleViewAllRanking = () => {
if (!id) {
@@ -192,7 +217,7 @@ export default function ChallengeDetailScreen() {
try {
Toast.show({
type: 'info',
text1: '正在生成分享卡片...',
text1: t('challengeDetail.share.generating'),
});
// 捕获分享卡片视图
@@ -203,8 +228,8 @@ export default function ChallengeDetailScreen() {
// 分享图片
const shareMessage = isJoined && progress
? `我正在参与「${challenge.title}」挑战,已完成 ${progress.completed}/${progress.target} 天!一起加入吧!`
: `发现一个很棒的挑战「${challenge.title}」,一起来参与吧!`;
? t('challengeDetail.share.messageJoined', { title: challenge.title, completed: progress.completed, target: progress.target })
: t('challengeDetail.share.messageNotJoined', { title: challenge.title });
await Share.share({
title: challenge.title,
@@ -213,7 +238,7 @@ export default function ChallengeDetailScreen() {
});
} catch (error) {
console.warn('分享失败', error);
Toast.error('分享失败,请稍后重试');
Toast.error(t('challengeDetail.share.failed'));
}
};
@@ -234,7 +259,7 @@ export default function ChallengeDetailScreen() {
await dispatch(fetchChallengeRankings({ id }));
setShowCelebration(true)
} catch (error) {
Toast.error('加入挑战失败')
Toast.error(t('challengeDetail.alert.joinFailed'))
}
};
@@ -246,7 +271,7 @@ export default function ChallengeDetailScreen() {
await dispatch(leaveChallenge(id)).unwrap();
await dispatch(fetchChallengeDetail(id)).unwrap();
} catch (error) {
Toast.error('退出挑战失败');
Toast.error(t('challengeDetail.alert.leaveFailed'));
}
};
@@ -254,34 +279,76 @@ export default function ChallengeDetailScreen() {
if (!id || leaveStatus === 'loading') {
return;
}
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
{ text: '取消', style: 'cancel' },
{
text: '退出挑战',
style: 'destructive',
onPress: () => {
void handleLeave();
Alert.alert(
t('challengeDetail.alert.leaveConfirm.title'),
t('challengeDetail.alert.leaveConfirm.message'),
[
{ text: t('challengeDetail.alert.leaveConfirm.cancel'), style: 'cancel' },
{
text: t('challengeDetail.alert.leaveConfirm.confirm'),
style: 'destructive',
onPress: () => {
void handleLeave();
},
},
},
]);
]
);
};
const handleProgressReport = () => {
const handleProgressReport = async () => {
if (!id || progressStatus === 'loading') {
return;
}
dispatch(reportChallengeProgress({ id }));
if (hasCheckedInToday) {
Toast.info(t('challengeDetail.checkIn.toast.alreadyChecked'));
return;
}
if (challenge?.status === 'upcoming') {
Toast.info(t('challengeDetail.checkIn.toast.notStarted'));
return;
}
if (challenge?.status === 'expired') {
Toast.info(t('challengeDetail.checkIn.toast.expired'));
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
if (!isJoined) {
Toast.info(t('challengeDetail.checkIn.toast.mustJoin'));
return;
}
try {
await dispatch(reportChallengeProgress({ id, value: 1 })).unwrap();
Toast.success(t('challengeDetail.checkIn.toast.success'));
} catch (error) {
Toast.error(t('challengeDetail.checkIn.toast.failed'));
}
};
const handleCopyShareCode = async () => {
if (!challenge?.shareCode) return;
await Clipboard.setStringAsync(challenge.shareCode);
// 添加震动反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Toast.success(t('challengeDetail.shareCode.copied'));
};
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} />
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.notFound')}</Text>
</View>
</SafeAreaView>
);
@@ -290,10 +357,10 @@ export default function ChallengeDetailScreen() {
if (isLoadingInitial) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<HeaderBar title={t('challengeDetail.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>
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>{t('challengeDetail.loading')}</Text>
</View>
</SafeAreaView>
);
@@ -302,43 +369,43 @@ export default function ChallengeDetailScreen() {
if (!challenge) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '未找到该挑战,稍后再试试吧。'}
{detailError ?? t('challengeDetail.notFound')}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallengeDetail(id))}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challengeDetail.retry')}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
const highlightTitle = challenge.highlightTitle ?? t('challengeDetail.highlight.join.title');
const highlightSubtitle = challenge.highlightSubtitle ?? t('challengeDetail.highlight.join.subtitle');
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
const isUpcoming = challenge.status === 'upcoming';
const isExpired = challenge.status === 'expired';
const upcomingStartLabel = formatMonthDay(challenge.startAt);
const upcomingHighlightTitle = '挑战即将开始';
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
const upcomingHighlightSubtitle = upcomingStartLabel
? `${upcomingStartLabel} 开始,敬请期待`
: '挑战即将开启,敬请期待';
const upcomingCtaLabel = '挑战即将开始';
? t('challengeDetail.highlight.upcoming.subtitle', { date: upcomingStartLabel })
: t('challengeDetail.highlight.upcoming.subtitleFallback');
const upcomingCtaLabel = t('challengeDetail.cta.upcoming');
const expiredEndLabel = formatMonthDay(challenge.endAt);
const expiredHighlightTitle = '挑战已结束';
const expiredHighlightTitle = t('challengeDetail.highlight.expired.title');
const expiredHighlightSubtitle = expiredEndLabel
? `${expiredEndLabel} 已截止,期待下一次挑战`
: '本轮挑战已结束,期待下一次挑战';
const expiredCtaLabel = '挑战已结束';
const leaveHighlightTitle = '先别急着离开';
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
? t('challengeDetail.highlight.expired.subtitle', { date: expiredEndLabel })
: t('challengeDetail.highlight.expired.subtitleFallback');
const expiredCtaLabel = t('challengeDetail.cta.expired');
const leaveHighlightTitle = t('challengeDetail.highlight.leave.title');
const leaveHighlightSubtitle = t('challengeDetail.highlight.leave.subtitle');
const leaveCtaLabel = leaveStatus === 'loading' ? t('challengeDetail.cta.leaving') : t('challengeDetail.cta.leave');
let floatingHighlightTitle = highlightTitle;
let floatingHighlightSubtitle = highlightSubtitle;
@@ -349,8 +416,10 @@ export default function ChallengeDetailScreen() {
let isDisabledButtonState = false;
if (isJoined) {
floatingHighlightTitle = leaveHighlightTitle;
floatingHighlightSubtitle = leaveHighlightSubtitle;
floatingHighlightTitle = showShareCode
? `分享码 ${challenge?.shareCode ?? ''}`
: leaveHighlightTitle;
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
floatingCtaLabel = leaveCtaLabel;
floatingOnPress = handleLeaveConfirm;
floatingDisabled = leaveStatus === 'loading';
@@ -380,6 +449,23 @@ export default function ChallengeDetailScreen() {
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
const checkInDisabled =
progressStatus === 'loading' || hasCheckedInToday || !isJoined || isUpcoming || isExpired;
const checkInButtonLabel =
progressStatus === 'loading'
? t('challengeDetail.checkIn.button.checking')
: hasCheckedInToday
? t('challengeDetail.checkIn.button.checked')
: !isJoined
? t('challengeDetail.checkIn.button.notJoined')
: isUpcoming
? t('challengeDetail.checkIn.button.upcoming')
: isExpired
? t('challengeDetail.checkIn.button.expired')
: t('challengeDetail.checkIn.button.checkIn');
const checkInSubtitle = hasCheckedInToday
? t('challengeDetail.checkIn.subtitleChecked')
: t('challengeDetail.checkIn.subtitle');
return (
<View style={styles.safeArea}>
@@ -411,9 +497,9 @@ export default function ChallengeDetailScreen() {
// 已加入:显示个人进度
<View style={styles.shareProgressContainer}>
<View style={styles.shareProgressHeader}>
<Text style={styles.shareProgressLabel}></Text>
<Text style={styles.shareProgressLabel}>{t('challengeDetail.shareCard.progress.label')}</Text>
<Text style={styles.shareProgressValue}>
{progress.completed} / {progress.target}
{t('challengeDetail.shareCard.progress.days', { completed: progress.completed, target: progress.target })}
</Text>
</View>
@@ -429,8 +515,8 @@ export default function ChallengeDetailScreen() {
<Text style={styles.shareProgressSubtext}>
{progress.completed === progress.target
? '🎉 已完成挑战!'
: `还差 ${progress.target - progress.completed} 天完成挑战`}
? t('challengeDetail.shareCard.progress.completed')
: t('challengeDetail.shareCard.progress.remaining', { remaining: progress.target - progress.completed })}
</Text>
</View>
) : (
@@ -454,7 +540,7 @@ export default function ChallengeDetailScreen() {
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
<Text style={styles.shareInfoMeta}></Text>
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.checkInDaily')}</Text>
</View>
</View>
@@ -464,7 +550,7 @@ export default function ChallengeDetailScreen() {
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{participantsLabel}</Text>
<Text style={styles.shareInfoMeta}></Text>
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.joinUs')}</Text>
</View>
</View>
</View>
@@ -472,7 +558,7 @@ export default function ChallengeDetailScreen() {
{/* 底部标识 */}
<View style={styles.shareCardFooter}>
<Text style={styles.shareCardFooterText}>Out Live · </Text>
<Text style={styles.shareCardFooterText}>{t('challengeDetail.shareCard.footer')}</Text>
</View>
</View>
</View>
@@ -568,7 +654,7 @@ export default function ChallengeDetailScreen() {
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
<Text style={styles.detailMeta}></Text>
<Text style={styles.detailMeta}>{t('challengeDetail.detail.requirement')}</Text>
</View>
</View>
@@ -590,19 +676,50 @@ export default function ChallengeDetailScreen() {
))}
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
<Text style={styles.moreAvatarText}>{t('challengeDetail.participants.more')}</Text>
</TouchableOpacity>
) : null}
</View>
) : null}
</View>
</View>
{isCustomChallenge ? (
<View style={styles.checkInCard}>
<View style={styles.checkInCopy}>
<Text style={styles.checkInTitle}>{hasCheckedInToday ? t('challengeDetail.checkIn.todayChecked') : t('challengeDetail.checkIn.title')}</Text>
<Text style={styles.checkInSubtitle}>{checkInSubtitle}</Text>
</View>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleProgressReport}
disabled={checkInDisabled}
style={styles.checkInButton}
>
<LinearGradient
colors={checkInDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.checkInButtonBackground}
>
<Text
style={[
styles.checkInButtonLabel,
checkInDisabled && styles.checkInButtonLabelDisabled,
]}
>
{checkInButtonLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
) : null}
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('challengeDetail.ranking.title')}</Text>
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
<Text style={styles.sectionAction}></Text>
<Text style={styles.sectionAction}>{t('challengeDetail.detail.viewAllRanking')}</Text>
</TouchableOpacity>
</View>
@@ -623,7 +740,7 @@ export default function ChallengeDetailScreen() {
))
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
<Text style={styles.emptyRankingText}>{t('challengeDetail.ranking.empty')}</Text>
</View>
)}
</View>
@@ -632,11 +749,30 @@ export default function ChallengeDetailScreen() {
<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>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.85}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<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}
@@ -732,6 +868,19 @@ const styles = StyleSheet.create({
flex: 1,
marginRight: 16,
},
highlightCopyCompact: {
marginRight: 12,
gap: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
shareCodeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: HERO_HEIGHT - 60,
@@ -834,6 +983,49 @@ const styles = StyleSheet.create({
color: '#4F5BD5',
fontWeight: '600',
},
checkInCard: {
marginTop: 4,
padding: 14,
borderRadius: 18,
backgroundColor: '#f5f6ff',
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
checkInCopy: {
flex: 1,
},
checkInTitle: {
fontSize: 14,
fontWeight: '700',
color: '#1c1f3a',
},
checkInSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
lineHeight: 18,
},
checkInButton: {
borderRadius: 18,
overflow: 'hidden',
},
checkInButtonBackground: {
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 18,
minWidth: 96,
alignItems: 'center',
justifyContent: 'center',
},
checkInButtonLabel: {
fontSize: 13,
fontWeight: '700',
color: '#ffffff',
},
checkInButtonLabelDisabled: {
color: '#6f7799',
},
sectionHeader: {
marginTop: 36,
marginHorizontal: 24,
@@ -889,6 +1081,10 @@ const styles = StyleSheet.create({
color: '#5f6a97',
lineHeight: 18,
},
shareCodeIconButton: {
paddingHorizontal: 4,
paddingVertical: 4,
},
ctaErrorText: {
marginTop: 8,
fontSize: 12,
@@ -1084,4 +1280,3 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
});

View File

@@ -0,0 +1,976 @@
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi';
import {
createCustomChallengeThunk,
fetchChallenges,
selectCreateChallengeError,
selectCreateChallengeStatus,
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [
{ value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' },
{ value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' },
{ value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' },
{ value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' },
{ value: ChallengeType.MOOD, label: '心情', accent: '#F97316' },
{ value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' },
];
const FALLBACK_IMAGE =
'https://images.unsplash.com/photo-1506126613408-eca07ce68773?auto=format&fit=crop&w=1200&q=80';
type PickerType = 'start' | 'end' | null;
export default function CreateCustomChallengeScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const router = useRouter();
const insets = useSafeAreaInsets();
const createStatus = useAppSelector(selectCreateChallengeStatus);
const createError = useAppSelector(selectCreateChallengeError);
const isCreating = createStatus === 'loading';
const today = useMemo(() => dayjs().startOf('day').toDate(), []);
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
const [title, setTitle] = useState('');
const [image, setImage] = useState<string | undefined>(FALLBACK_IMAGE);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const { upload, uploading } = useCosUpload({ prefix: 'images/challenges' });
const [type, setType] = useState<ChallengeType>(ChallengeType.WATER);
const [startDate, setStartDate] = useState<Date>(today);
const [endDate, setEndDate] = useState<Date>(defaultEnd);
const [targetValue, setTargetValue] = useState('');
const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
const [requirementLabel, setRequirementLabel] = useState('');
const [summary, setSummary] = useState('');
const [progressUnit] = useState('天');
const [periodLabel, setPeriodLabel] = useState('');
const [periodEdited, setPeriodEdited] = useState(false);
const [rankingDescription] = useState('连续打卡榜');
const [isPublic, setIsPublic] = useState(true);
const [maxParticipants, setMaxParticipants] = useState('100');
const [minimumEdited, setMinimumEdited] = useState(false);
const [shareCode, setShareCode] = useState<string | null>(null);
const [shareModalVisible, setShareModalVisible] = useState(false);
const [createdChallengeId, setCreatedChallengeId] = useState<string | null>(null);
const [pickerType, setPickerType] = useState<PickerType>(null);
const durationDays = useMemo(
() =>
Math.max(
1,
dayjs(endDate).startOf('day').diff(dayjs(startDate).startOf('day'), 'day') + 1
),
[startDate, endDate]
);
const durationLabel = useMemo(() => `持续${durationDays}`, [durationDays]);
useEffect(() => {
if (!periodEdited) {
setPeriodLabel(`${durationDays}天挑战`);
}
if (!minimumEdited) {
setMinimumCheckInDays(String(durationDays));
}
}, [durationDays, minimumEdited, periodEdited]);
const handleConfirmDate = (date: Date) => {
if (!pickerType) return;
const normalized = dayjs(date).startOf('day');
if (pickerType === 'start') {
const nextStart = normalized.isAfter(dayjs(), 'day')
? normalized
: dayjs().add(1, 'day').startOf('day');
setStartDate(nextStart.toDate());
if (dayjs(endDate).isSameOrBefore(nextStart)) {
const nextEnd = nextStart.add(20, 'day').toDate();
setEndDate(nextEnd);
}
} else {
const minEnd = dayjs(startDate).add(1, 'day').startOf('day');
const nextEnd = normalized.isAfter(minEnd) ? normalized : minEnd;
setEndDate(nextEnd.toDate());
}
setPickerType(null);
};
const handleSubmit = async () => {
if (isCreating) return;
if (!title.trim()) {
Toast.warning('请填写挑战标题');
return;
}
if (!requirementLabel.trim()) {
Toast.warning('请填写挑战要求说明');
return;
}
const startTimestamp = dayjs(startDate).valueOf();
const endTimestamp = dayjs(endDate).valueOf();
if (endTimestamp <= startTimestamp) {
Toast.warning('结束时间需要晚于开始时间');
return;
}
const target = Number(targetValue);
if (!Number.isFinite(target) || target < 1 || target > 1000) {
Toast.warning('每日目标值需在 1-1000 之间');
return;
}
const minDays = Number(minimumCheckInDays) || durationDays;
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
Toast.warning('最少打卡天数需在 1-365 之间');
return;
}
if (minDays > durationDays) {
Toast.warning('最少打卡天数不能超过持续天数');
return;
}
const maxP = maxParticipants ? Number(maxParticipants) : null;
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制');
return;
}
const safeTitle = title.trim() || '自定义挑战';
const payload: CreateCustomChallengePayload = {
title: safeTitle,
type,
image: image?.trim() || undefined,
startAt: startTimestamp,
endAt: endTimestamp,
targetValue: target,
minimumCheckInDays: minDays,
durationLabel,
requirementLabel: requirementLabel.trim() || '请填写挑战要求',
summary: summary.trim() || undefined,
progressUnit: progressUnit.trim() || '天',
periodLabel: periodLabel.trim() || undefined,
rankingDescription: rankingDescription.trim() || undefined,
isPublic,
maxParticipants: maxP,
};
try {
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
setShareCode(created.shareCode ?? null);
setCreatedChallengeId(created.id);
setShareModalVisible(true);
Toast.success('自定义挑战已创建');
dispatch(fetchChallenges());
} catch (error) {
const message = typeof error === 'string' ? error : '创建失败,请稍后再试';
Toast.error(message);
}
};
const handleCopyShareCode = async () => {
if (!shareCode) return;
await Clipboard.setStringAsync(shareCode);
Toast.success('邀请码已复制');
};
const handleTargetInputChange = (value: string) => {
const digits = value.replace(/\D/g, '');
if (!digits) {
setTargetValue('');
return;
}
const num = Math.min(1000, parseInt(digits, 10));
setTargetValue(String(num));
};
const handleMinimumDaysChange = (value: string) => {
const digits = value.replace(/\D/g, '');
if (!digits) {
setMinimumCheckInDays('');
setMinimumEdited(true);
return;
}
const num = Math.max(1, Math.min(365, parseInt(digits, 10)));
if (num > durationDays) {
setMinimumCheckInDays(String(durationDays));
setMinimumEdited(true);
return;
}
setMinimumEdited(true);
setMinimumCheckInDays(String(num));
};
const handlePickImage = useCallback(() => {
Alert.alert(
'选择封面图',
'请选择封面来源',
[
{
text: '拍照',
onPress: async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄封面');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.6,
aspect: [16, 9],
});
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
setImagePreview(asset.uri);
setImage(undefined);
try {
const { url } = await upload(
{
uri: asset.uri,
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
type: asset.mimeType ?? 'image/jpeg',
},
{ prefix: 'images/challenges' }
);
setImage(url);
setImagePreview(null);
} catch (error) {
console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试');
}
} catch (error) {
console.error('[CHALLENGE] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
}
},
},
{
text: '从相册选择',
onPress: async () => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择封面');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.9,
});
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
setImagePreview(asset.uri);
setImage(undefined);
try {
const { url } = await upload(
{
uri: asset.uri,
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
type: asset.mimeType ?? 'image/jpeg',
},
{ prefix: 'images/challenges' }
);
setImage(url);
setImagePreview(null);
} catch (error) {
console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试');
}
} catch (error) {
console.error('[CHALLENGE] 选择封面失败', error);
Alert.alert('选择失败', '无法打开相册,请稍后再试');
}
},
},
{ text: '取消', style: 'cancel' },
],
{ cancelable: true }
);
}, [upload]);
const handleViewChallenge = () => {
setShareModalVisible(false);
if (createdChallengeId) {
router.replace({ pathname: '/challenges/[id]', params: { id: createdChallengeId } });
}
};
const renderField = (
label: string,
value: string,
onChange: (val: string) => void,
placeholder?: string,
keyboardType: 'default' | 'numeric' = 'default',
onFocus?: () => void
) => (
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor="#9ca3af"
style={styles.input}
keyboardType={keyboardType}
onFocus={onFocus}
/>
</View>
);
const renderTextarea = (
label: string,
value: string,
onChange: (val: string) => void,
placeholder?: string
) => (
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.textarea]}
multiline
textAlignVertical="top"
/>
</View>
);
const progressMeta = `${durationDays} 天 · ${progressUnit || '天'}`;
const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
return (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<LinearGradient
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
style={StyleSheet.absoluteFillObject}
/>
<HeaderBar title="新建挑战" transparent />
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={80}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 140) + insets.bottom },
]}
>
<View style={styles.heroContainer}>
<Image
source={{ uri: heroImageSource }}
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 style={styles.heroOverlay}>
<Text style={styles.heroKicker}></Text>
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text>
<Text style={styles.heroMeta}>{progressMeta}</Text>
</View>
</View>
<View style={styles.formCard}>
<View style={styles.formHeader}>
<Text style={styles.sectionTitle}></Text>
{createError ? <Text style={styles.inlineError}>{createError}</Text> : null}
</View>
{renderField('标题', title, setTitle, '挑战标题最多100字')}
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.uploadRow}>
<TouchableOpacity
activeOpacity={0.9}
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
onPress={handlePickImage}
disabled={uploading}
>
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text>
</TouchableOpacity>
{image || imagePreview ? (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
setImagePreview(null);
setImage(undefined);
}}
>
<Text style={styles.clearUpload}></Text>
</TouchableOpacity>
) : null}
</View>
<Text style={styles.helperText}> 16:9</Text>
</View>
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')}
</View>
<View style={styles.formCard}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.chipRow}>
{typeOptions.map((option) => {
const active = option.value === type;
return (
<TouchableOpacity
key={option.value}
activeOpacity={0.9}
onPress={() => setType(option.value)}
style={[
styles.chip,
active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent },
]}
>
<Text
style={[
styles.chipLabel,
active && { color: option.accent, fontWeight: '700' },
]}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.dateRow}>
<TouchableOpacity
activeOpacity={0.9}
style={styles.datePill}
onPress={() => setPickerType('start')}
>
<Text style={styles.dateLabel}></Text>
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
style={styles.datePill}
onPress={() => setPickerType('end')}
>
<Text style={styles.dateLabel}></Text>
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.inlineFields}>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{durationLabel}</Text>
</View>
</View>
{renderField('周期标签', periodLabel, (v) => {
setPeriodEdited(true);
setPeriodLabel(v);
}, '如21天挑战')}
</View>
<View style={styles.inlineFields}>
{renderField('每日目标值', targetValue, handleTargetInputChange, '如8', 'numeric')}
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{progressUnit}</Text>
</View>
</View>
</View>
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')}
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')}
</View>
<View style={styles.formCard}>
<Text style={styles.sectionTitle}>&</Text>
<View style={styles.inlineFields}>
{renderField('参与人数上限', maxParticipants, (v) => {
const digits = v.replace(/\D/g, '');
if (!digits) {
setMaxParticipants('');
return;
}
setMaxParticipants(String(parseInt(digits, 10)));
}, '留空表示无限制', 'numeric')}
</View>
<View style={styles.switchRow}>
<View>
<Text style={styles.fieldLabel}></Text>
<Text style={styles.switchHint}></Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ true: colorTokens.primary, false: '#cbd5e1' }}
thumbColor={isPublic ? '#ffffff' : undefined}
/>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
<View pointerEvents="box-none" style={[styles.floatingCTA, { paddingBottom: insets.bottom + 12 }]}>
<BlurView intensity={14} tint="light" style={styles.floatingBlur}>
<View style={styles.floatingContent}>
<View style={styles.floatingCopy}>
<Text style={styles.floatingTitle}></Text>
<Text style={styles.floatingSubtitle}></Text>
</View>
<TouchableOpacity
activeOpacity={0.9}
style={styles.floatingButton}
onPress={handleSubmit}
disabled={isCreating}
>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.floatingButtonBackground}
>
<Text style={styles.floatingButtonLabel}>
{isCreating ? '创建中…' : '创建并生成邀请码'}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</BlurView>
</View>
<DateTimePickerModal
isVisible={pickerType !== null}
mode="date"
date={pickerType === 'end' ? endDate : startDate}
minimumDate={pickerType === 'end' ? dayjs(startDate).add(1, 'day').toDate() : dayjs().add(1, 'day').toDate()}
onConfirm={handleConfirmDate}
onCancel={() => setPickerType(null)}
/>
<Modal
visible={shareModalVisible}
transparent
animationType="fade"
onRequestClose={() => setShareModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.shareCard}>
<Text style={styles.shareTitle}></Text>
<Text style={styles.shareSubtitle}></Text>
<View style={styles.shareCodeBadge}>
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text>
</View>
<View style={styles.shareActions}>
<TouchableOpacity
activeOpacity={0.85}
style={styles.shareButtonGhost}
onPress={handleCopyShareCode}
disabled={!shareCode}
>
<Text style={styles.shareButtonGhostLabel}></Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
style={styles.shareButtonPrimary}
onPress={handleViewChallenge}
>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.shareButtonPrimary}
>
<Text style={styles.shareButtonPrimaryLabel}></Text>
</LinearGradient>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.shareClose}
activeOpacity={0.8}
onPress={() => setShareModalVisible(false)}
>
<Text style={styles.shareCloseLabel}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
scrollContent: {
paddingBottom: 160,
},
heroContainer: {
height: 260,
width: '100%',
overflow: 'hidden',
},
heroImage: {
width: '100%',
height: '100%',
},
heroOverlay: {
position: 'absolute',
bottom: 22,
left: 20,
right: 20,
},
heroKicker: {
color: '#f8fafc',
fontSize: 13,
letterSpacing: 1.2,
fontWeight: '700',
},
heroTitle: {
marginTop: 8,
fontSize: 26,
fontWeight: '800',
color: '#ffffff',
textShadowColor: 'rgba(0,0,0,0.25)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 6,
},
heroMeta: {
marginTop: 6,
fontSize: 14,
color: '#e2e8f0',
fontWeight: '600',
},
formCard: {
marginTop: 14,
marginHorizontal: 20,
padding: 18,
borderRadius: 22,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: 12 },
elevation: 8,
gap: 10,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
color: '#0f172a',
},
fieldBlock: {
gap: 6,
},
fieldLabel: {
fontSize: 14,
fontWeight: '700',
color: '#0f172a',
},
input: {
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
fontSize: 15,
color: '#111827',
},
textarea: {
minHeight: 90,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
chip: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 14,
backgroundColor: '#f8fafc',
borderWidth: 1,
borderColor: '#e5e7eb',
},
chipLabel: {
fontSize: 13,
color: '#334155',
},
uploadRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
uploadButton: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
backgroundColor: '#EEF1FF',
borderWidth: 1,
borderColor: '#d1d5db',
},
uploadButtonDisabled: {
opacity: 0.7,
},
uploadButtonLabel: {
fontSize: 14,
fontWeight: '700',
color: '#4F5BD5',
},
clearUpload: {
fontSize: 13,
fontWeight: '600',
color: '#9ca3af',
},
helperText: {
marginTop: 6,
fontSize: 12,
color: '#6b7280',
},
dateRow: {
flexDirection: 'row',
gap: 12,
},
datePill: {
flex: 1,
padding: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
},
dateLabel: {
fontSize: 12,
color: '#6b7280',
},
dateValue: {
marginTop: 4,
fontSize: 15,
fontWeight: '700',
color: '#0f172a',
},
readonlyPill: {
marginTop: 6,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
},
readonlyText: {
fontSize: 15,
fontWeight: '700',
color: '#0f172a',
},
inlineFields: {
gap: 12,
},
switchRow: {
marginTop: 6,
padding: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
switchHint: {
marginTop: 4,
fontSize: 12,
color: '#6b7280',
},
formHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
inlineError: {
fontSize: 12,
color: '#ef4444',
},
floatingCTA: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 16,
paddingTop: 10,
},
floatingBlur: {
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.9)',
},
floatingContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 14,
},
floatingCopy: {
flex: 1,
},
floatingTitle: {
fontSize: 15,
fontWeight: '800',
color: '#0f172a',
},
floatingSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#6b7280',
},
floatingButton: {
borderRadius: 16,
overflow: 'hidden',
},
floatingButtonBackground: {
paddingHorizontal: 18,
paddingVertical: 12,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
floatingButtonLabel: {
fontSize: 14,
fontWeight: '800',
color: '#ffffff',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
shareCard: {
width: '100%',
padding: 20,
borderRadius: 22,
backgroundColor: '#ffffff',
shadowColor: 'rgba(15, 23, 42, 0.18)',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 12,
alignItems: 'center',
gap: 10,
},
shareTitle: {
fontSize: 18,
fontWeight: '800',
color: '#0f172a',
},
shareSubtitle: {
fontSize: 13,
color: '#6b7280',
},
shareCodeBadge: {
marginTop: 10,
paddingHorizontal: 18,
paddingVertical: 12,
borderRadius: 16,
backgroundColor: '#EEF1FF',
},
shareCode: {
fontSize: 22,
fontWeight: '800',
color: '#4F5BD5',
letterSpacing: 2,
},
shareActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
width: '100%',
marginTop: 8,
},
shareButtonGhost: {
flex: 1,
paddingVertical: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#d1d5db',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f8fafc',
},
shareButtonGhostLabel: {
fontSize: 14,
fontWeight: '700',
color: '#475569',
},
shareButtonPrimary: {
flex: 1,
borderRadius: 14,
overflow: 'hidden',
},
shareButtonPrimaryLabel: {
textAlign: 'center',
fontSize: 14,
fontWeight: '800',
color: '#ffffff',
paddingVertical: 12,
},
shareClose: {
marginTop: 8,
paddingVertical: 10,
paddingHorizontal: 12,
},
shareCloseLabel: {
fontSize: 13,
color: '#6b7280',
},
});

View File

@@ -2,7 +2,8 @@ import { ThemedView } from '@/components/ThemedView';
import { ROUTES } from '@/constants/Routes';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { useThemeColor } from '@/hooks/useThemeColor';
import { preloadUserData } from '@/store/userSlice';
import { STORAGE_KEYS } from '@/services/api';
import AsyncStorage from '@/utils/kvStore';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, View } from 'react-native';
@@ -19,10 +20,11 @@ export default function SplashScreen() {
const checkOnboardingStatus = async () => {
try {
// 先预加载用户数据,包括 onboarding 状态
console.log('开始预加载用户数据(包含 onboarding 状态...');
const userData = await preloadUserData();
console.log('用户数据预加载完成onboarding 状态:', userData.onboardingCompleted);
// 直接读取 onboarding 状态
console.log('检查 onboarding 状态...');
const onboardingCompletedStr = await AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted);
const onboardingCompleted = onboardingCompletedStr === 'true';
console.log('Onboarding 状态:', onboardingCompleted);
// 初始化推送通知(不阻塞应用启动,且不会请求权限)
console.log('开始初始化推送通知基础服务...');
@@ -30,8 +32,8 @@ export default function SplashScreen() {
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
});
// 根据预加载的状态决定跳转
if (userData.onboardingCompleted) {
// 根据状态决定跳转
if (onboardingCompleted) {
console.log('用户已完成引导,跳转到统计页面');
router.replace(ROUTES.TAB_STATISTICS);
} else {
@@ -39,7 +41,7 @@ export default function SplashScreen() {
router.replace(ROUTES.ONBOARDING);
}
} catch (error) {
console.error('检查引导状态或预加载用户数据失败:', error);
console.error('检查引导状态失败:', error);
// 如果出现错误,默认进入主应用(假设已完成引导)
router.replace(ROUTES.TAB_STATISTICS);
}