feat(challenges): 实现自定义挑战的编辑与删除功能并完善多语言支持
- 新增自定义挑战的编辑模式,支持修改挑战信息 - 在详情页为创建者添加删除(归档)挑战的功能入口 - 全面完善挑战创建页面的国际化(i18n)文案适配 - 优化个人中心页面的字体样式,统一使用 AliBold/Regular - 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
This commit is contained in:
@@ -7,11 +7,15 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { ChallengeSource } from '@/services/challengesApi';
|
||||
import {
|
||||
archiveCustomChallengeThunk,
|
||||
fetchChallengeDetail,
|
||||
fetchChallengeRankings,
|
||||
fetchChallenges,
|
||||
joinChallenge,
|
||||
leaveChallenge,
|
||||
reportChallengeProgress,
|
||||
selectArchiveError,
|
||||
selectArchiveStatus,
|
||||
selectChallengeById,
|
||||
selectChallengeDetailError,
|
||||
selectChallengeDetailStatus,
|
||||
@@ -117,6 +121,10 @@ export default function ChallengeDetailScreen() {
|
||||
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 archiveStatusSelector = useMemo(() => (id ? selectArchiveStatus(id) : undefined), [id]);
|
||||
const archiveStatus = useAppSelector((state) => (archiveStatusSelector ? archiveStatusSelector(state) : 'idle'));
|
||||
const archiveErrorSelector = useMemo(() => (id ? selectArchiveError(id) : undefined), [id]);
|
||||
const archiveError = useAppSelector((state) => (archiveErrorSelector ? archiveErrorSelector(state) : undefined));
|
||||
|
||||
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
||||
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
|
||||
@@ -160,9 +168,13 @@ export default function ChallengeDetailScreen() {
|
||||
};
|
||||
}, [showCelebration]);
|
||||
|
||||
|
||||
const progress = challenge?.progress;
|
||||
const isJoined = challenge?.isJoined ?? false;
|
||||
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
|
||||
const isCreator = challenge?.isCreator ?? false;
|
||||
const isCustomCreator = isCustomChallenge && isCreator;
|
||||
const canEdit = isCustomChallenge && isCreator;
|
||||
const lastProgressAt = useMemo(() => {
|
||||
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
|
||||
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
|
||||
@@ -275,6 +287,20 @@ export default function ChallengeDetailScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!id || archiveStatus === 'loading') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await dispatch(archiveCustomChallengeThunk(id)).unwrap();
|
||||
Toast.success(t('challengeDetail.alert.archiveSuccess'));
|
||||
await dispatch(fetchChallenges());
|
||||
router.back();
|
||||
} catch (error) {
|
||||
Toast.error(t('challengeDetail.alert.archiveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveConfirm = () => {
|
||||
if (!id || leaveStatus === 'loading') {
|
||||
return;
|
||||
@@ -295,6 +321,26 @@ export default function ChallengeDetailScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleArchiveConfirm = () => {
|
||||
if (!id || archiveStatus === 'loading') {
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
t('challengeDetail.alert.archiveConfirm.title'),
|
||||
t('challengeDetail.alert.archiveConfirm.message'),
|
||||
[
|
||||
{ text: t('challengeDetail.alert.archiveConfirm.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('challengeDetail.alert.archiveConfirm.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void handleArchive();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleProgressReport = async () => {
|
||||
if (!id || progressStatus === 'loading') {
|
||||
return;
|
||||
@@ -391,6 +437,9 @@ export default function ChallengeDetailScreen() {
|
||||
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 deleteCtaLabel = archiveStatus === 'loading'
|
||||
? t('challengeDetail.cta.deleting')
|
||||
: t('challengeDetail.cta.delete');
|
||||
const upcomingStartLabel = formatMonthDay(challenge.startAt);
|
||||
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
|
||||
const upcomingHighlightSubtitle = upcomingStartLabel
|
||||
@@ -420,10 +469,17 @@ export default function ChallengeDetailScreen() {
|
||||
? `分享码 ${challenge?.shareCode ?? ''}`
|
||||
: leaveHighlightTitle;
|
||||
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
|
||||
floatingCtaLabel = leaveCtaLabel;
|
||||
floatingOnPress = handleLeaveConfirm;
|
||||
floatingDisabled = leaveStatus === 'loading';
|
||||
floatingError = leaveError;
|
||||
if (isCustomCreator) {
|
||||
floatingCtaLabel = deleteCtaLabel;
|
||||
floatingOnPress = handleArchiveConfirm;
|
||||
floatingDisabled = archiveStatus === 'loading';
|
||||
floatingError = archiveError;
|
||||
} else {
|
||||
floatingCtaLabel = leaveCtaLabel;
|
||||
floatingOnPress = handleLeaveConfirm;
|
||||
floatingDisabled = leaveStatus === 'loading';
|
||||
floatingError = leaveError;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUpcoming) {
|
||||
@@ -573,29 +629,63 @@ export default function ChallengeDetailScreen() {
|
||||
transparent
|
||||
withSafeTop={false}
|
||||
right={
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.shareButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
<View style={styles.headerButtons}>
|
||||
{canEdit && (
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({
|
||||
pathname: '/challenges/create-custom',
|
||||
params: { id, mode: 'edit' }
|
||||
})}
|
||||
activeOpacity={0.7}
|
||||
style={styles.editButton}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.editButtonGlass}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="create-outline" size={20} color="#ffffff" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({
|
||||
pathname: '/challenges/create-custom',
|
||||
params: { id, mode: 'edit' }
|
||||
})}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.editButton, styles.fallbackEditButton]}
|
||||
>
|
||||
<Ionicons name="create-outline" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
)}
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.shareButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
style={[styles.shareButton, styles.fallbackShareButton]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
style={[styles.shareButton, styles.fallbackShareButton]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -746,52 +836,129 @@ export default function ChallengeDetailScreen() {
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||||
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||||
<View style={styles.floatingCTAContent}>
|
||||
{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}
|
||||
onPress={floatingOnPress}
|
||||
disabled={floatingDisabled}
|
||||
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom || 20 }]}>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<View style={styles.glassWrapper}>
|
||||
{/* 顶部高光线条 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.9)', 'rgba(255,255,255,0.2)', 'transparent']}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
style={styles.glassHighlight}
|
||||
/>
|
||||
<GlassView
|
||||
style={styles.glassContainer}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(243, 244, 251, 0.55)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{/* 内部微光渐变 */}
|
||||
<LinearGradient
|
||||
colors={floatingGradientColors}
|
||||
colors={['rgba(255,255,255,0.6)', 'rgba(255,255,255,0.0)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.highlightButtonBackground}
|
||||
>
|
||||
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
|
||||
{floatingCtaLabel}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
end={{ x: 0, y: 0.6 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<View style={styles.floatingCTAContent}>
|
||||
{showShareCode ? (
|
||||
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
|
||||
<View style={styles.shareCodeRow}>
|
||||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
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.85}
|
||||
onPress={floatingOnPress}
|
||||
disabled={floatingDisabled}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={floatingGradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.highlightButtonBackground}
|
||||
>
|
||||
{/* 按钮内部高光 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.4)', 'transparent']}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 0.5 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
|
||||
{floatingCtaLabel}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</GlassView>
|
||||
</View>
|
||||
</BlurView>
|
||||
) : (
|
||||
<BlurView intensity={20} tint="light" style={styles.floatingCTABlur}>
|
||||
<View style={styles.floatingCTAContent}>
|
||||
{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}
|
||||
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 && (
|
||||
@@ -850,13 +1017,47 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 20,
|
||||
zIndex: 100,
|
||||
},
|
||||
floatingCTABlur: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
backgroundColor: 'rgba(243, 244, 251, 0.85)',
|
||||
backgroundColor: 'rgba(243, 244, 251, 0.9)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 5,
|
||||
},
|
||||
glassWrapper: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
shadowColor: '#5E8BFF',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 8,
|
||||
},
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
glassHighlight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 1,
|
||||
zIndex: 2,
|
||||
opacity: 0.9,
|
||||
},
|
||||
glassContainer: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
floatingCTAContent: {
|
||||
flexDirection: 'row',
|
||||
@@ -890,6 +1091,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: '#596095',
|
||||
letterSpacing: 0.2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
title: {
|
||||
marginTop: 10,
|
||||
@@ -897,6 +1099,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '800',
|
||||
color: '#1c1f3a',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
summary: {
|
||||
marginTop: 12,
|
||||
@@ -904,6 +1107,7 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 20,
|
||||
color: '#7080b4',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
inlineError: {
|
||||
marginTop: 12,
|
||||
@@ -950,11 +1154,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
detailMeta: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6f7ba7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
avatarRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -982,6 +1188,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
color: '#4F5BD5',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
checkInCard: {
|
||||
marginTop: 4,
|
||||
@@ -999,12 +1206,14 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
checkInSubtitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6f7ba7',
|
||||
lineHeight: 18,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
checkInButton: {
|
||||
borderRadius: 18,
|
||||
@@ -1022,6 +1231,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
checkInButtonLabelDisabled: {
|
||||
color: '#6f7799',
|
||||
@@ -1037,11 +1247,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
sectionAction: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#5F6BF0',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
sectionSubtitle: {
|
||||
marginTop: 8,
|
||||
@@ -1049,6 +1261,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
color: '#6f7ba7',
|
||||
lineHeight: 18,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
rankingCard: {
|
||||
marginTop: 20,
|
||||
@@ -1069,17 +1282,20 @@ const styles = StyleSheet.create({
|
||||
emptyRankingText: {
|
||||
fontSize: 14,
|
||||
color: '#6f7ba7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
highlightTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
highlightSubtitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#5f6a97',
|
||||
lineHeight: 18,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
shareCodeIconButton: {
|
||||
paddingHorizontal: 4,
|
||||
@@ -1105,10 +1321,38 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
fontFamily: 'AliBold',
|
||||
|
||||
},
|
||||
highlightButtonLabelDisabled: {
|
||||
color: '#6f7799',
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
editButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
editButtonGlass: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackEditButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.24)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
shareButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -1131,6 +1375,7 @@ const styles = StyleSheet.create({
|
||||
missingText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 18,
|
||||
@@ -1142,6 +1387,7 @@ const styles = StyleSheet.create({
|
||||
retryText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
celebrationOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -1185,6 +1431,7 @@ const styles = StyleSheet.create({
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
shareCardSummary: {
|
||||
fontSize: 15,
|
||||
@@ -1195,6 +1442,7 @@ const styles = StyleSheet.create({
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.25)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
shareProgressContainer: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
@@ -1229,11 +1477,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
shareInfoMeta: {
|
||||
fontSize: 12,
|
||||
color: '#707baf',
|
||||
marginTop: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
shareProgressHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -1245,11 +1495,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
shareProgressValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#5E8BFF',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
shareProgressTrack: {
|
||||
height: 8,
|
||||
@@ -1268,6 +1520,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: 12,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
shareCardFooter: {
|
||||
alignItems: 'center',
|
||||
@@ -1278,5 +1531,6 @@ const styles = StyleSheet.create({
|
||||
color: '#ffffff',
|
||||
opacity: 0.8,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
@@ -27,22 +27,32 @@ 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 { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
ChallengeType,
|
||||
type CreateCustomChallengePayload,
|
||||
type UpdateCustomChallengePayload,
|
||||
} from '@/services/challengesApi';
|
||||
import { store } from '@/store';
|
||||
import {
|
||||
createCustomChallengeThunk,
|
||||
fetchChallenges,
|
||||
selectChallengeById,
|
||||
selectCreateChallengeError,
|
||||
selectCreateChallengeStatus,
|
||||
selectUpdateChallengeError,
|
||||
selectUpdateChallengeStatus,
|
||||
updateCustomChallengeThunk
|
||||
} 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 getTypeOptions = (t: (key: string) => string): { value: ChallengeType; label: string; accent: string }[] => [
|
||||
{ value: ChallengeType.WATER, label: t('challenges.createCustom.typeLabels.water'), accent: '#5E8BFF' },
|
||||
{ value: ChallengeType.EXERCISE, label: t('challenges.createCustom.typeLabels.exercise'), accent: '#6B6CFF' },
|
||||
{ value: ChallengeType.DIET, label: t('challenges.createCustom.typeLabels.diet'), accent: '#38BDF8' },
|
||||
{ value: ChallengeType.SLEEP, label: t('challenges.createCustom.typeLabels.sleep'), accent: '#7C3AED' },
|
||||
{ value: ChallengeType.MOOD, label: t('challenges.createCustom.typeLabels.mood'), accent: '#F97316' },
|
||||
{ value: ChallengeType.WEIGHT, label: t('challenges.createCustom.typeLabels.weight'), accent: '#22C55E' },
|
||||
];
|
||||
|
||||
const FALLBACK_IMAGE =
|
||||
@@ -51,6 +61,9 @@ const FALLBACK_IMAGE =
|
||||
type PickerType = 'start' | 'end' | null;
|
||||
|
||||
export default function CreateCustomChallengeScreen() {
|
||||
const { id, mode } = useLocalSearchParams<{ id?: string; mode?: 'edit' }>();
|
||||
const isEditMode = mode === 'edit' && !!id;
|
||||
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -58,7 +71,14 @@ export default function CreateCustomChallengeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const createStatus = useAppSelector(selectCreateChallengeStatus);
|
||||
const createError = useAppSelector(selectCreateChallengeError);
|
||||
const updateError = useAppSelector(selectUpdateChallengeError);
|
||||
const updateStatus = useAppSelector(selectUpdateChallengeStatus);
|
||||
const inlineError = isEditMode ? updateError : createError;
|
||||
|
||||
const isCreating = createStatus === 'loading';
|
||||
const isUpdating = updateStatus === 'loading';
|
||||
const { t } = useI18n();
|
||||
const typeOptions = useMemo(() => getTypeOptions(t), [t]);
|
||||
|
||||
const today = useMemo(() => dayjs().startOf('day').toDate(), []);
|
||||
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
|
||||
@@ -74,7 +94,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
|
||||
const [requirementLabel, setRequirementLabel] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [progressUnit] = useState('天');
|
||||
const [progressUnit, setProgressUnit] = useState('');
|
||||
const [periodLabel, setPeriodLabel] = useState('');
|
||||
const [periodEdited, setPeriodEdited] = useState(false);
|
||||
const [rankingDescription] = useState('连续打卡榜');
|
||||
@@ -88,6 +108,29 @@ export default function CreateCustomChallengeScreen() {
|
||||
|
||||
const [pickerType, setPickerType] = useState<PickerType>(null);
|
||||
|
||||
// 编辑模式下预填充数据
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
const challengeSelector = selectChallengeById(id);
|
||||
const challenge = challengeSelector(store.getState());
|
||||
if (challenge) {
|
||||
setTitle(challenge.title || '');
|
||||
setImage(challenge.image);
|
||||
setType(challenge.type);
|
||||
setStartDate(new Date(challenge.startAt || Date.now()));
|
||||
setEndDate(new Date(challenge.endAt || Date.now()));
|
||||
setTargetValue(String(challenge.progress?.target || ''));
|
||||
setMinimumCheckInDays(String(challenge.minimumCheckInDays || ''));
|
||||
setRequirementLabel(challenge.requirementLabel || '');
|
||||
setSummary(challenge.summary || '');
|
||||
setProgressUnit(challenge.unit || '');
|
||||
setPeriodLabel(challenge.periodLabel || '');
|
||||
setIsPublic(challenge.isPublic ?? true);
|
||||
setMaxParticipants(challenge.maxParticipants?.toString() || '100');
|
||||
}
|
||||
}
|
||||
}, [isEditMode, id]);
|
||||
|
||||
const durationDays = useMemo(
|
||||
() =>
|
||||
Math.max(
|
||||
@@ -96,16 +139,16 @@ export default function CreateCustomChallengeScreen() {
|
||||
),
|
||||
[startDate, endDate]
|
||||
);
|
||||
const durationLabel = useMemo(() => `持续${durationDays}天`, [durationDays]);
|
||||
const durationLabel = useMemo(() => t('challenges.createCustom.durationDays', { days: durationDays }), [durationDays, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!periodEdited) {
|
||||
setPeriodLabel(`${durationDays}天挑战`);
|
||||
setPeriodLabel(t('challenges.createCustom.durationDaysChallenge', { days: durationDays }));
|
||||
}
|
||||
if (!minimumEdited) {
|
||||
setMinimumCheckInDays(String(durationDays));
|
||||
}
|
||||
}, [durationDays, minimumEdited, periodEdited]);
|
||||
}, [durationDays, minimumEdited, periodEdited, t]);
|
||||
|
||||
const handleConfirmDate = (date: Date) => {
|
||||
if (!pickerType) return;
|
||||
@@ -128,47 +171,47 @@ export default function CreateCustomChallengeScreen() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isCreating) return;
|
||||
if (isCreating || isUpdating) return;
|
||||
if (!title.trim()) {
|
||||
Toast.warning('请填写挑战标题');
|
||||
Toast.warning(t('challenges.createCustom.alerts.titleRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requirementLabel.trim()) {
|
||||
Toast.warning('请填写挑战要求说明');
|
||||
if (!requirementLabel.trim()) {
|
||||
Toast.warning(t('challenges.createCustom.alerts.requirementRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const startTimestamp = dayjs(startDate).valueOf();
|
||||
const endTimestamp = dayjs(endDate).valueOf();
|
||||
if (endTimestamp <= startTimestamp) {
|
||||
Toast.warning('结束时间需要晚于开始时间');
|
||||
Toast.warning(t('challenges.createCustom.alerts.endTimeError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const target = Number(targetValue);
|
||||
if (!Number.isFinite(target) || target < 1 || target > 1000) {
|
||||
Toast.warning('每日目标值需在 1-1000 之间');
|
||||
Toast.warning(t('challenges.createCustom.alerts.targetValueError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const minDays = Number(minimumCheckInDays) || durationDays;
|
||||
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
|
||||
Toast.warning('最少打卡天数需在 1-365 之间');
|
||||
Toast.warning(t('challenges.createCustom.alerts.minimumDaysError'));
|
||||
return;
|
||||
}
|
||||
if (minDays > durationDays) {
|
||||
Toast.warning('最少打卡天数不能超过持续天数');
|
||||
Toast.warning(t('challenges.createCustom.alerts.minimumDaysExceedError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const maxP = maxParticipants ? Number(maxParticipants) : null;
|
||||
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
|
||||
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制');
|
||||
Toast.warning(t('challenges.createCustom.alerts.participantsError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTitle = title.trim() || '自定义挑战';
|
||||
const safeTitle = title.trim() || t('challenges.createCustom.defaultTitle');
|
||||
const payload: CreateCustomChallengePayload = {
|
||||
title: safeTitle,
|
||||
type,
|
||||
@@ -178,24 +221,39 @@ export default function CreateCustomChallengeScreen() {
|
||||
targetValue: target,
|
||||
minimumCheckInDays: minDays,
|
||||
durationLabel,
|
||||
requirementLabel: requirementLabel.trim() || '请填写挑战要求',
|
||||
requirementLabel: requirementLabel.trim(),
|
||||
summary: summary.trim() || undefined,
|
||||
progressUnit: progressUnit.trim() || '天',
|
||||
progressUnit: progressUnit.trim(),
|
||||
periodLabel: periodLabel.trim() || undefined,
|
||||
rankingDescription: rankingDescription.trim() || undefined,
|
||||
isPublic,
|
||||
maxParticipants: maxP,
|
||||
};
|
||||
|
||||
const updatePayload: UpdateCustomChallengePayload = {
|
||||
title: safeTitle,
|
||||
image: image?.trim() || undefined,
|
||||
summary: summary.trim() || undefined,
|
||||
isPublic,
|
||||
maxParticipants: maxP ?? undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEditMode && id) {
|
||||
await dispatch(updateCustomChallengeThunk({ id, payload: updatePayload })).unwrap();
|
||||
Toast.success(t('challenges.createCustom.alerts.updateSuccess'));
|
||||
dispatch(fetchChallenges());
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
|
||||
setShareCode(created.shareCode ?? null);
|
||||
setCreatedChallengeId(created.id);
|
||||
setShareModalVisible(true);
|
||||
Toast.success('自定义挑战已创建');
|
||||
Toast.success(t('challenges.createCustom.alerts.createSuccess'));
|
||||
dispatch(fetchChallenges());
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : '创建失败,请稍后再试';
|
||||
const message = typeof error === 'string' ? error : t('challenges.createCustom.alerts.createFailed');
|
||||
Toast.error(message);
|
||||
}
|
||||
};
|
||||
@@ -203,7 +261,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
const handleCopyShareCode = async () => {
|
||||
if (!shareCode) return;
|
||||
await Clipboard.setStringAsync(shareCode);
|
||||
Toast.success('邀请码已复制');
|
||||
Toast.success(t('challenges.createCustom.shareModal.copyCode'));
|
||||
};
|
||||
|
||||
const handleTargetInputChange = (value: string) => {
|
||||
@@ -235,16 +293,16 @@ export default function CreateCustomChallengeScreen() {
|
||||
|
||||
const handlePickImage = useCallback(() => {
|
||||
Alert.alert(
|
||||
'选择封面图',
|
||||
'请选择封面来源',
|
||||
t('challenges.createCustom.imageUpload.selectSource'),
|
||||
t('challenges.createCustom.imageUpload.selectMessage'),
|
||||
[
|
||||
{
|
||||
text: '拍照',
|
||||
text: t('challenges.createCustom.imageUpload.camera'),
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相机权限以拍摄封面');
|
||||
Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.cameraPermissionMessage'));
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
@@ -269,21 +327,21 @@ export default function CreateCustomChallengeScreen() {
|
||||
setImagePreview(null);
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 封面上传失败', error);
|
||||
Alert.alert('上传失败', '封面上传失败,请稍后重试');
|
||||
Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 拍照失败', error);
|
||||
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
|
||||
Alert.alert(t('challenges.createCustom.imageUpload.cameraFailed'), t('challenges.createCustom.imageUpload.cameraFailedMessage'));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '从相册选择',
|
||||
text: t('challenges.createCustom.imageUpload.album'),
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相册权限以选择封面');
|
||||
Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.albumPermissionMessage'));
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
@@ -307,19 +365,19 @@ export default function CreateCustomChallengeScreen() {
|
||||
setImagePreview(null);
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 封面上传失败', error);
|
||||
Alert.alert('上传失败', '封面上传失败,请稍后重试');
|
||||
Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 选择封面失败', error);
|
||||
Alert.alert('选择失败', '无法打开相册,请稍后再试');
|
||||
Alert.alert(t('challenges.createCustom.imageUpload.selectFailed'), t('challenges.createCustom.imageUpload.selectFailedMessage'));
|
||||
}
|
||||
},
|
||||
},
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: t('challenges.createCustom.imageUpload.cancel'), style: 'cancel' },
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
}, [upload]);
|
||||
}, [upload, t]);
|
||||
|
||||
const handleViewChallenge = () => {
|
||||
setShareModalVisible(false);
|
||||
@@ -370,7 +428,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
const progressMeta = `${durationDays} 天 · ${progressUnit || '天'}`;
|
||||
const progressMeta = `${durationDays} ${t('challenges.createCustom.dayUnit')}${progressUnit ? ` · ${progressUnit}` : ''}`;
|
||||
const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
|
||||
|
||||
return (
|
||||
@@ -379,7 +437,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<HeaderBar title="新建挑战" transparent />
|
||||
<HeaderBar title={isEditMode ? t('challenges.createCustom.editTitle') : t('challenges.createCustom.title')} transparent />
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
@@ -403,20 +461,20 @@ export default function CreateCustomChallengeScreen() {
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<View style={styles.heroOverlay}>
|
||||
<Text style={styles.heroKicker}>自定义挑战</Text>
|
||||
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text>
|
||||
<Text style={styles.heroKicker}>{t('challenges.customChallenges')}</Text>
|
||||
<Text style={styles.heroTitle}>{title || t('challenges.createCustom.yourChallenge')}</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}
|
||||
<Text style={styles.sectionTitle}>{t('challenges.createCustom.basicInfo')}</Text>
|
||||
{inlineError ? <Text style={styles.inlineError}>{inlineError}</Text> : null}
|
||||
</View>
|
||||
{renderField('标题', title, setTitle, '挑战标题(最多100字)')}
|
||||
{renderField(t('challenges.createCustom.fields.title'), title, setTitle, t('challenges.createCustom.fields.titlePlaceholder'))}
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>封面图</Text>
|
||||
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.coverImage')}</Text>
|
||||
<View style={styles.uploadRow}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
@@ -424,7 +482,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
onPress={handlePickImage}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text>
|
||||
<Text style={styles.uploadButtonLabel}>{uploading ? t('challenges.createCustom.imageUpload.uploading') : t('challenges.createCustom.fields.uploadCover')}</Text>
|
||||
</TouchableOpacity>
|
||||
{image || imagePreview ? (
|
||||
<TouchableOpacity
|
||||
@@ -434,20 +492,20 @@ export default function CreateCustomChallengeScreen() {
|
||||
setImage(undefined);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.clearUpload}>清除</Text>
|
||||
<Text style={styles.clearUpload}>{t('challenges.createCustom.imageUpload.clear')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.helperText}>建议比例 16:9,清晰展示挑战氛围</Text>
|
||||
<Text style={styles.helperText}>{t('challenges.createCustom.imageUpload.helper')}</Text>
|
||||
</View>
|
||||
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')}
|
||||
{renderTextarea(t('challenges.createCustom.fields.challengeDescription'), summary, setSummary, t('challenges.createCustom.fields.descriptionPlaceholder'))}
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.sectionTitle}>挑战设置</Text>
|
||||
<Text style={styles.sectionTitle}>{t('challenges.createCustom.challengeSettings')}</Text>
|
||||
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>挑战类型</Text>
|
||||
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.challengeType')}</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{typeOptions.map((option) => {
|
||||
const active = option.value === type;
|
||||
@@ -476,14 +534,14 @@ export default function CreateCustomChallengeScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>时间范围</Text>
|
||||
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.timeRange')}</Text>
|
||||
<View style={styles.dateRow}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.datePill}
|
||||
onPress={() => setPickerType('start')}
|
||||
>
|
||||
<Text style={styles.dateLabel}>开始</Text>
|
||||
<Text style={styles.dateLabel}>{t('challenges.createCustom.fields.start')}</Text>
|
||||
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -491,7 +549,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
style={styles.datePill}
|
||||
onPress={() => setPickerType('end')}
|
||||
>
|
||||
<Text style={styles.dateLabel}>结束</Text>
|
||||
<Text style={styles.dateLabel}>{t('challenges.createCustom.fields.end')}</Text>
|
||||
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -499,48 +557,60 @@ export default function CreateCustomChallengeScreen() {
|
||||
|
||||
<View style={styles.inlineFields}>
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>持续时间</Text>
|
||||
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.duration')}</Text>
|
||||
<View style={styles.readonlyPill}>
|
||||
<Text style={styles.readonlyText}>{durationLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{renderField('周期标签', periodLabel, (v) => {
|
||||
{renderField(t('challenges.createCustom.fields.periodLabel'), periodLabel, (v) => {
|
||||
setPeriodEdited(true);
|
||||
setPeriodLabel(v);
|
||||
}, '如:21天挑战')}
|
||||
}, t('challenges.createCustom.fields.periodLabelPlaceholder'))}
|
||||
</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 style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.dailyTargetAndUnit')}</Text>
|
||||
<View style={styles.targetUnitRow}>
|
||||
<TextInput
|
||||
value={targetValue}
|
||||
onChangeText={handleTargetInputChange}
|
||||
placeholder={t('challenges.createCustom.fields.dailyTargetPlaceholder')}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.input, styles.targetInput]}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TextInput
|
||||
value={progressUnit}
|
||||
onChangeText={setProgressUnit}
|
||||
placeholder={t('challenges.createCustom.fields.unitPlaceholder')}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.input, styles.unitInput]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.helperText}>{t('challenges.createCustom.fields.unitHelper')}</Text>
|
||||
</View>
|
||||
|
||||
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')}
|
||||
{renderField(t('challenges.createCustom.fields.minimumCheckInDays'), minimumCheckInDays, handleMinimumDaysChange, t('challenges.createCustom.fields.minimumCheckInDaysPlaceholder'), 'numeric')}
|
||||
|
||||
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')}
|
||||
{renderField(t('challenges.createCustom.fields.challengeRequirement'), requirementLabel, setRequirementLabel, t('challenges.createCustom.fields.requirementPlaceholder'))}
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.sectionTitle}>展示&互动</Text>
|
||||
<Text style={styles.sectionTitle}>{t('challenges.createCustom.displayInteraction')}</Text>
|
||||
<View style={styles.inlineFields}>
|
||||
{renderField('参与人数上限', maxParticipants, (v) => {
|
||||
{renderField(t('challenges.createCustom.fields.maxParticipants'), maxParticipants, (v) => {
|
||||
const digits = v.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
setMaxParticipants('');
|
||||
return;
|
||||
}
|
||||
setMaxParticipants(String(parseInt(digits, 10)));
|
||||
}, '留空表示无限制', 'numeric')}
|
||||
}, t('challenges.createCustom.fields.noLimit'), 'numeric')}
|
||||
</View>
|
||||
<View style={styles.switchRow}>
|
||||
<View>
|
||||
<Text style={styles.fieldLabel}>是否公开</Text>
|
||||
<Text style={styles.switchHint}>公开后其他用户可通过邀请码加入</Text>
|
||||
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.isPublic')}</Text>
|
||||
<Text style={styles.switchHint}>{t('challenges.createCustom.fields.publicDescription')}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
@@ -557,14 +627,24 @@ export default function CreateCustomChallengeScreen() {
|
||||
<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>
|
||||
<Text style={styles.floatingTitle}>
|
||||
{isEditMode
|
||||
? t('challenges.createCustom.floatingCTA.editTitle')
|
||||
: t('challenges.createCustom.floatingCTA.title')
|
||||
}
|
||||
</Text>
|
||||
<Text style={styles.floatingSubtitle}>
|
||||
{isEditMode
|
||||
? t('challenges.createCustom.floatingCTA.editSubtitle')
|
||||
: t('challenges.createCustom.floatingCTA.subtitle')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.floatingButton}
|
||||
onPress={handleSubmit}
|
||||
disabled={isCreating}
|
||||
disabled={isCreating || isUpdating}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#5E8BFF', '#6B6CFF']}
|
||||
@@ -573,7 +653,14 @@ export default function CreateCustomChallengeScreen() {
|
||||
style={styles.floatingButtonBackground}
|
||||
>
|
||||
<Text style={styles.floatingButtonLabel}>
|
||||
{isCreating ? '创建中…' : '创建并生成邀请码'}
|
||||
{isCreating
|
||||
? t('challenges.createCustom.buttons.creating')
|
||||
: isUpdating
|
||||
? t('challenges.createCustom.buttons.updating')
|
||||
: isEditMode
|
||||
? t('challenges.createCustom.buttons.updateAndSave')
|
||||
: t('challenges.createCustom.buttons.createAndGenerateCode')
|
||||
}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
@@ -598,10 +685,10 @@ export default function CreateCustomChallengeScreen() {
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.shareCard}>
|
||||
<Text style={styles.shareTitle}>邀请码已生成</Text>
|
||||
<Text style={styles.shareSubtitle}>分享给好友即可加入挑战</Text>
|
||||
<Text style={styles.shareTitle}>{t('challenges.createCustom.shareModal.title')}</Text>
|
||||
<Text style={styles.shareSubtitle}>{t('challenges.createCustom.shareModal.subtitle')}</Text>
|
||||
<View style={styles.shareCodeBadge}>
|
||||
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text>
|
||||
<Text style={styles.shareCode}>{shareCode ?? t('challenges.createCustom.shareModal.generatingCode')}</Text>
|
||||
</View>
|
||||
<View style={styles.shareActions}>
|
||||
<TouchableOpacity
|
||||
@@ -610,7 +697,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
onPress={handleCopyShareCode}
|
||||
disabled={!shareCode}
|
||||
>
|
||||
<Text style={styles.shareButtonGhostLabel}>复制邀请码</Text>
|
||||
<Text style={styles.shareButtonGhostLabel}>{t('challenges.createCustom.shareModal.copyCode')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
@@ -623,7 +710,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.shareButtonPrimary}
|
||||
>
|
||||
<Text style={styles.shareButtonPrimaryLabel}>查看挑战</Text>
|
||||
<Text style={styles.shareButtonPrimaryLabel}>{t('challenges.createCustom.shareModal.viewChallenge')}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -632,7 +719,7 @@ export default function CreateCustomChallengeScreen() {
|
||||
activeOpacity={0.8}
|
||||
onPress={() => setShareModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.shareCloseLabel}>稍后再说</Text>
|
||||
<Text style={styles.shareCloseLabel}>{t('challenges.createCustom.shareModal.later')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -720,6 +807,16 @@ const styles = StyleSheet.create({
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
},
|
||||
targetUnitRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
targetInput: {
|
||||
flex: 1,
|
||||
},
|
||||
unitInput: {
|
||||
flex: 1,
|
||||
},
|
||||
textarea: {
|
||||
minHeight: 90,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user