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

@@ -243,7 +243,7 @@ export default function PersonalScreen() {
</View> </View>
{isLgAvaliable ? ( {isLgAvaliable ? (
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}> <TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<GlassView style={styles.editButtonGlass}> <GlassView style={styles.editButtonGlass} isInteractive>
<Text style={styles.editButtonTextGlass}>{profileActionLabel}</Text> <Text style={styles.editButtonTextGlass}>{profileActionLabel}</Text>
</GlassView> </GlassView>
</TouchableOpacity> </TouchableOpacity>
@@ -834,7 +834,6 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
}, },
editButtonGlass: { editButtonGlass: {
backgroundColor: '#ffffff',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, paddingVertical: 8,
borderRadius: 16, borderRadius: 16,

View File

@@ -24,11 +24,12 @@ import {
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native'; import LottieView from 'lottie-react-native';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -43,6 +44,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { captureRef } from 'react-native-view-shot';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.76; const HERO_HEIGHT = width * 0.76;
@@ -88,6 +90,9 @@ export default function ChallengeDetailScreen() {
const { ensureLoggedIn } = useAuthGuard(); const { ensureLoggedIn } = useAuthGuard();
// 用于截图分享的引用
const shareCardRef = useRef<View>(null);
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]); const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined)); const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
@@ -180,17 +185,35 @@ export default function ChallengeDetailScreen() {
); );
const handleShare = async () => { const handleShare = async () => {
if (!challenge) { if (!challenge || !shareCardRef.current) {
return; return;
} }
try { 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({ await Share.share({
title: challenge.title, title: challenge.title,
message: `我正在参与「${challenge.title}」,一起坚持吧!`, message: shareMessage,
url: challenge.image, url: Platform.OS === 'ios' ? uri : `file://${uri}`,
}); });
} catch (error) { } catch (error) {
console.warn('分享失败', error); console.warn('分享失败', error);
Toast.error('分享失败,请稍后重试');
} }
}; };
@@ -357,8 +380,104 @@ export default function ChallengeDetailScreen() {
const participantsLabel = formatParticipantsLabel(challenge.participantsCount); const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined; const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
return ( return (
<View style={styles.safeArea}> <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" /> <StatusBar barStyle="light-content" />
<View style={styles.container}> <View style={styles.container}>
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}> <View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
@@ -367,11 +486,31 @@ export default function ChallengeDetailScreen() {
tone="light" tone="light"
transparent transparent
withSafeTop={false} withSafeTop={false}
// right={ right={
// <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}> isLiquidGlassAvailable() ? (
// <Ionicons name="share-social-outline" size={20} color="#ffffff" /> <TouchableOpacity
// </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> </View>
@@ -774,21 +913,19 @@ const styles = StyleSheet.create({
highlightButtonLabelDisabled: { highlightButtonLabelDisabled: {
color: '#6f7799', color: '#6f7799',
}, },
circularButton: { shareButton: {
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20, borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.24)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden',
},
fallbackShareButton: {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)', borderColor: 'rgba(255, 255, 255, 0.45)',
}, },
shareIcon: {
fontSize: 18,
color: '#ffffff',
fontWeight: '700',
},
missingContainer: { missingContainer: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
@@ -820,4 +957,130 @@ const styles = StyleSheet.create({
width: width * 1.3, width: width * 1.3,
height: 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',
},
}); });