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