feat(ui): 添加挑战详情分享卡片功能和玻璃态按钮
- 实现挑战分享卡片截图生成,根据参与状态展示不同内容 - 已参与用户显示个人进度条和完成情况 - 未参与用户显示挑战详细信息和参与邀请 - 使用 GlassView 组件优化分享按钮和编辑按钮的视觉效果 - 添加 react-native-view-shot 支持视图截图 - 移除硬编码背景色,统一使用玻璃态交互效果
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user