feat(challenges): 实现自定义挑战的编辑与删除功能并完善多语言支持

- 新增自定义挑战的编辑模式,支持修改挑战信息
- 在详情页为创建者添加删除(归档)挑战的功能入口
- 全面完善挑战创建页面的国际化(i18n)文案适配
- 优化个人中心页面的字体样式,统一使用 AliBold/Regular
- 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
This commit is contained in:
richarjiang
2025-11-26 19:07:19 +08:00
parent 39671ed70f
commit 518282ecb8
6 changed files with 866 additions and 160 deletions

View File

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