feat(ui): 添加挑战详情分享卡片功能和玻璃态按钮

- 实现挑战分享卡片截图生成,根据参与状态展示不同内容
- 已参与用户显示个人进度条和完成情况
- 未参与用户显示挑战详细信息和参与邀请
- 使用 GlassView 组件优化分享按钮和编辑按钮的视觉效果
- 添加 react-native-view-shot 支持视图截图
- 移除硬编码背景色,统一使用玻璃态交互效果
This commit is contained in:
richarjiang
2025-11-14 10:02:44 +08:00
parent 6ad77bc0e2
commit 6c2f9295be
2 changed files with 281 additions and 19 deletions

View File

@@ -24,11 +24,12 @@ import {
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
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, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
@@ -43,6 +44,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { captureRef } from 'react-native-view-shot';
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.76;
@@ -87,6 +89,9 @@ export default function ChallengeDetailScreen() {
const insets = useSafeAreaInsets();
const { ensureLoggedIn } = useAuthGuard();
// 用于截图分享的引用
const shareCardRef = useRef<View>(null);
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
@@ -180,17 +185,35 @@ export default function ChallengeDetailScreen() {
);
const handleShare = async () => {
if (!challenge) {
if (!challenge || !shareCardRef.current) {
return;
}
try {
Toast.show({
type: 'info',
text1: '正在生成分享卡片...',
});
// 捕获分享卡片视图
const uri = await captureRef(shareCardRef, {
format: 'png',
quality: 0.9,
});
// 分享图片
const shareMessage = isJoined && progress
? `我正在参与「${challenge.title}」挑战,已完成 ${progress.completed}/${progress.target} 天!一起加入吧!`
: `发现一个很棒的挑战「${challenge.title}」,一起来参与吧!`;
await Share.share({
title: challenge.title,
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
url: challenge.image,
message: shareMessage,
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
});
} catch (error) {
console.warn('分享失败', error);
Toast.error('分享失败,请稍后重试');
}
};
@@ -357,8 +380,104 @@ export default function ChallengeDetailScreen() {
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
return (
<View style={styles.safeArea}>
{/* 隐藏的分享卡片,用于截图 */}
<View style={styles.offscreenContainer}>
<View ref={shareCardRef} style={styles.shareCard} collapsable={false}>
{/* 背景图片 */}
<Image
source={{ uri: challenge.image }}
style={styles.shareCardBg}
cachePolicy={'memory-disk'}
/>
<LinearGradient
colors={['rgba(0,0,0,0.4)', 'rgba(0,0,0,0.65)']}
style={StyleSheet.absoluteFillObject}
/>
{/* 分享卡片内容 */}
<View style={styles.shareCardContent}>
<Text style={styles.shareCardTitle}>{challenge.title}</Text>
{challenge.summary ? (
<Text style={styles.shareCardSummary} numberOfLines={2}>
{challenge.summary}
</Text>
) : null}
{/* 根据是否加入显示不同内容 */}
{isJoined && progress ? (
// 已加入:显示个人进度
<View style={styles.shareProgressContainer}>
<View style={styles.shareProgressHeader}>
<Text style={styles.shareProgressLabel}></Text>
<Text style={styles.shareProgressValue}>
{progress.completed} / {progress.target}
</Text>
</View>
{/* 进度条 */}
<View style={styles.shareProgressTrack}>
<View
style={[
styles.shareProgressBar,
{ width: `${Math.min(100, (progress.completed / progress.target) * 100)}%` }
]}
/>
</View>
<Text style={styles.shareProgressSubtext}>
{progress.completed === progress.target
? '🎉 已完成挑战!'
: `还差 ${progress.target - progress.completed} 天完成挑战`}
</Text>
</View>
) : (
// 未加入:显示挑战信息
<View style={styles.shareInfoContainer}>
<View style={styles.shareInfoRow}>
<View style={styles.shareInfoIconWrapper}>
<Ionicons name="calendar-outline" size={20} color="#5E8BFF" />
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{dateRangeLabel}</Text>
{challenge.durationLabel ? (
<Text style={styles.shareInfoMeta}>{challenge.durationLabel}</Text>
) : null}
</View>
</View>
<View style={styles.shareInfoRow}>
<View style={styles.shareInfoIconWrapper}>
<Ionicons name="flag-outline" size={20} color="#5E8BFF" />
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
<Text style={styles.shareInfoMeta}></Text>
</View>
</View>
<View style={styles.shareInfoRow}>
<View style={styles.shareInfoIconWrapper}>
<Ionicons name="people-outline" size={20} color="#5E8BFF" />
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{participantsLabel}</Text>
<Text style={styles.shareInfoMeta}></Text>
</View>
</View>
</View>
)}
{/* 底部标识 */}
<View style={styles.shareCardFooter}>
<Text style={styles.shareCardFooterText}>Out Live · </Text>
</View>
</View>
</View>
</View>
<StatusBar barStyle="light-content" />
<View style={styles.container}>
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
@@ -367,11 +486,31 @@ export default function ChallengeDetailScreen() {
tone="light"
transparent
withSafeTop={false}
// right={
// <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
// <Ionicons name="share-social-outline" size={20} color="#ffffff" />
// </TouchableOpacity>
// }
right={
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" />
</TouchableOpacity>
)
}
/>
</View>
@@ -774,20 +913,18 @@ const styles = StyleSheet.create({
highlightButtonLabelDisabled: {
color: '#6f7799',
},
circularButton: {
shareButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.24)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.45)',
overflow: 'hidden',
},
shareIcon: {
fontSize: 18,
color: '#ffffff',
fontWeight: '700',
fallbackShareButton: {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
missingContainer: {
flex: 1,
@@ -820,4 +957,130 @@ const styles = StyleSheet.create({
width: width * 1.3,
height: width * 1.3,
},
// 分享卡片样式
offscreenContainer: {
position: 'absolute',
left: -9999,
top: -9999,
opacity: 0,
},
shareCard: {
width: 375,
height: 500,
backgroundColor: '#fff',
overflow: 'hidden',
borderRadius: 24,
},
shareCardBg: {
...StyleSheet.absoluteFillObject,
width: '100%',
height: '100%',
},
shareCardContent: {
flex: 1,
padding: 24,
justifyContent: 'space-between',
},
shareCardTitle: {
fontSize: 28,
fontWeight: '800',
color: '#ffffff',
marginTop: 20,
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
shareCardSummary: {
fontSize: 15,
color: '#ffffff',
marginTop: 12,
lineHeight: 22,
opacity: 0.95,
textShadowColor: 'rgba(0, 0, 0, 0.25)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
shareProgressContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: 20,
padding: 20,
marginTop: 'auto',
},
shareInfoContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: 20,
padding: 20,
marginTop: 'auto',
gap: 16,
},
shareInfoRow: {
flexDirection: 'row',
alignItems: 'center',
},
shareInfoIconWrapper: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#EEF0FF',
alignItems: 'center',
justifyContent: 'center',
},
shareInfoTextWrapper: {
marginLeft: 12,
flex: 1,
},
shareInfoLabel: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
},
shareInfoMeta: {
fontSize: 12,
color: '#707baf',
marginTop: 2,
},
shareProgressHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
shareProgressLabel: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
},
shareProgressValue: {
fontSize: 18,
fontWeight: '800',
color: '#5E8BFF',
},
shareProgressTrack: {
height: 8,
backgroundColor: '#eceffa',
borderRadius: 4,
overflow: 'hidden',
},
shareProgressBar: {
height: '100%',
backgroundColor: '#5E8BFF',
borderRadius: 4,
},
shareProgressSubtext: {
fontSize: 13,
color: '#707baf',
marginTop: 12,
textAlign: 'center',
fontWeight: '500',
},
shareCardFooter: {
alignItems: 'center',
paddingTop: 16,
},
shareCardFooterText: {
fontSize: 12,
color: '#ffffff',
opacity: 0.8,
fontWeight: '600',
},
});