- Updated app.json to set userInterfaceStyle to automatic for improved theme adaptability. - Refactored layout components to utilize useAppColors for dynamic theming, ensuring consistent color usage. - Introduced SystemChrome component to manage system UI background color based on theme. - Enhanced TabsLayout, ProfileStackLayout, and other components to leverage new theming structure. - Improved QRCodeScanner, SearchBar, and CommentItem styles to align with the updated theme system. - Consolidated styles in SystemMessageItem and TabBar for better maintainability and visual coherence.
374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
/**
|
|
* VoteCard 投票卡片组件
|
|
* 显示投票选项列表,支持投票和取消投票
|
|
* 风格与现代整体UI保持一致
|
|
*/
|
|
|
|
import React, { useCallback, useMemo } from 'react';
|
|
import {
|
|
View,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
Animated,
|
|
} from 'react-native';
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
|
import { VoteOptionDTO } from '../../types';
|
|
import Text from '../common/Text';
|
|
|
|
interface VoteCardProps {
|
|
postId?: string;
|
|
options: VoteOptionDTO[];
|
|
totalVotes: number;
|
|
hasVoted: boolean;
|
|
votedOptionId?: string;
|
|
onVote: (optionId: string) => void;
|
|
onUnvote: () => void;
|
|
isLoading?: boolean;
|
|
compact?: boolean;
|
|
}
|
|
|
|
function createVoteCardStyles(colors: AppColors) {
|
|
return StyleSheet.create({
|
|
container: {
|
|
backgroundColor: colors.background.paper,
|
|
borderRadius: borderRadius.lg,
|
|
padding: spacing.md,
|
|
marginVertical: spacing.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.divider,
|
|
},
|
|
containerCompact: {
|
|
padding: spacing.sm,
|
|
marginVertical: spacing.xs,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginBottom: spacing.sm,
|
|
},
|
|
headerIcon: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: borderRadius.sm,
|
|
backgroundColor: colors.primary.light + '20',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginRight: spacing.sm,
|
|
},
|
|
headerTitle: {
|
|
fontWeight: '600',
|
|
color: colors.text.primary,
|
|
},
|
|
optionsList: {
|
|
gap: spacing.sm,
|
|
},
|
|
optionContainer: {
|
|
position: 'relative',
|
|
borderRadius: borderRadius.md,
|
|
overflow: 'hidden',
|
|
},
|
|
progressBar: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 0,
|
|
borderRadius: borderRadius.md,
|
|
},
|
|
optionButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: spacing.sm,
|
|
paddingHorizontal: spacing.md,
|
|
borderRadius: borderRadius.md,
|
|
backgroundColor: colors.background.default,
|
|
minHeight: 44,
|
|
borderWidth: 1,
|
|
borderColor: 'transparent',
|
|
},
|
|
optionButtonVoted: {
|
|
borderColor: colors.primary.main,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
optionIndicator: {
|
|
width: 18,
|
|
height: 18,
|
|
borderRadius: borderRadius.full,
|
|
borderWidth: 2,
|
|
borderColor: colors.divider,
|
|
marginRight: spacing.sm,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
optionIndicatorVoted: {
|
|
backgroundColor: colors.primary.main,
|
|
borderColor: colors.primary.main,
|
|
},
|
|
optionText: {
|
|
flex: 1,
|
|
fontSize: fontSizes.md,
|
|
color: colors.text.primary,
|
|
lineHeight: 20,
|
|
},
|
|
optionTextCompact: {
|
|
fontSize: fontSizes.sm,
|
|
lineHeight: 18,
|
|
},
|
|
resultContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginLeft: spacing.sm,
|
|
minWidth: 40,
|
|
justifyContent: 'flex-end',
|
|
},
|
|
percentage: {
|
|
color: colors.text.secondary,
|
|
fontWeight: '500',
|
|
},
|
|
percentageVoted: {
|
|
color: colors.primary.main,
|
|
fontWeight: '700',
|
|
},
|
|
footer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginTop: spacing.md,
|
|
paddingTop: spacing.sm,
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderTopColor: colors.divider,
|
|
},
|
|
footerLeft: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
},
|
|
footerText: {
|
|
fontSize: fontSizes.sm,
|
|
},
|
|
unvoteButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingVertical: spacing.xs,
|
|
paddingHorizontal: spacing.sm,
|
|
borderRadius: borderRadius.sm,
|
|
backgroundColor: colors.background.default,
|
|
},
|
|
unvoteText: {
|
|
fontSize: fontSizes.sm,
|
|
},
|
|
loadingOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: colors.background.paper + 'CC',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
borderRadius: borderRadius.lg,
|
|
},
|
|
});
|
|
}
|
|
|
|
const VoteCard: React.FC<VoteCardProps> = ({
|
|
options,
|
|
totalVotes,
|
|
hasVoted,
|
|
votedOptionId,
|
|
onVote,
|
|
onUnvote,
|
|
isLoading = false,
|
|
compact = false,
|
|
}) => {
|
|
const colors = useAppColors();
|
|
const styles = useMemo(() => createVoteCardStyles(colors), [colors]);
|
|
const progressAnim = React.useRef(new Animated.Value(0)).current;
|
|
|
|
React.useEffect(() => {
|
|
Animated.timing(progressAnim, {
|
|
toValue: 1,
|
|
duration: 400,
|
|
useNativeDriver: false,
|
|
}).start();
|
|
}, [hasVoted, totalVotes]);
|
|
|
|
// 计算百分比
|
|
const calculatePercentage = useCallback((votes: number): number => {
|
|
if (totalVotes === 0) return 0;
|
|
return Math.round((votes / totalVotes) * 100);
|
|
}, [totalVotes]);
|
|
|
|
// 格式化票数
|
|
const formatVoteCount = useCallback((count: number): string => {
|
|
if (count >= 10000) {
|
|
return (count / 10000).toFixed(1) + '万';
|
|
}
|
|
if (count >= 1000) {
|
|
return (count / 1000).toFixed(1) + 'k';
|
|
}
|
|
return count.toString();
|
|
}, []);
|
|
|
|
// 处理投票
|
|
const handleVote = useCallback((optionId: string) => {
|
|
if (isLoading || hasVoted) return;
|
|
onVote(optionId);
|
|
}, [isLoading, hasVoted, onVote]);
|
|
|
|
// 处理取消投票
|
|
const handleUnvote = useCallback(() => {
|
|
if (isLoading || !hasVoted) return;
|
|
onUnvote();
|
|
}, [isLoading, hasVoted, onUnvote]);
|
|
|
|
// 渲染投票选项
|
|
const renderOption = useCallback((option: VoteOptionDTO, index: number) => {
|
|
const isVotedOption = votedOptionId === option.id;
|
|
const percentage = calculatePercentage(option.votes_count);
|
|
const showResults = hasVoted;
|
|
|
|
return (
|
|
<View key={option.id} style={styles.optionContainer}>
|
|
{/* 进度条背景 */}
|
|
{showResults && (
|
|
<Animated.View
|
|
style={[
|
|
styles.progressBar,
|
|
{
|
|
width: progressAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ['0%', `${percentage}%`],
|
|
}),
|
|
backgroundColor: isVotedOption
|
|
? colors.primary.light + '40'
|
|
: colors.background.disabled,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{/* 选项按钮 */}
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.optionButton,
|
|
isVotedOption && styles.optionButtonVoted,
|
|
]}
|
|
onPress={() => handleVote(option.id)}
|
|
disabled={isLoading || hasVoted}
|
|
activeOpacity={hasVoted ? 1 : 0.8}
|
|
>
|
|
{/* 选择指示器 */}
|
|
<View style={[
|
|
styles.optionIndicator,
|
|
isVotedOption && styles.optionIndicatorVoted,
|
|
]}>
|
|
{isVotedOption && (
|
|
<MaterialCommunityIcons
|
|
name="check"
|
|
size={12}
|
|
color={colors.primary.contrast}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{/* 选项内容 */}
|
|
<Text
|
|
variant={compact ? 'caption' : 'body'}
|
|
style={compact ? [styles.optionText, styles.optionTextCompact] : styles.optionText}
|
|
numberOfLines={compact ? 1 : 2}
|
|
>
|
|
{option.content}
|
|
</Text>
|
|
|
|
{/* 投票结果 */}
|
|
{showResults && (
|
|
<View style={styles.resultContainer}>
|
|
<Text
|
|
variant="caption"
|
|
style={isVotedOption ? [styles.percentage, styles.percentageVoted] : styles.percentage}
|
|
>
|
|
{percentage}%
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]);
|
|
|
|
// 排序后的选项(已投票的排在前面)
|
|
const sortedOptions = React.useMemo(() => {
|
|
if (!hasVoted) return options;
|
|
return [...options].sort((a, b) => {
|
|
if (a.id === votedOptionId) return -1;
|
|
if (b.id === votedOptionId) return 1;
|
|
return b.votes_count - a.votes_count;
|
|
});
|
|
}, [options, hasVoted, votedOptionId]);
|
|
|
|
return (
|
|
<View style={[styles.container, compact && styles.containerCompact]}>
|
|
{/* 投票图标和标题 */}
|
|
<View style={styles.header}>
|
|
<View style={styles.headerIcon}>
|
|
<MaterialCommunityIcons
|
|
name="vote"
|
|
size={compact ? 14 : 16}
|
|
color={colors.primary.main}
|
|
/>
|
|
</View>
|
|
<Text variant={compact ? 'caption' : 'body'} style={styles.headerTitle}>
|
|
投票
|
|
</Text>
|
|
</View>
|
|
|
|
{/* 投票选项列表 */}
|
|
<View style={styles.optionsList}>
|
|
{sortedOptions.map((option, index) => renderOption(option, index))}
|
|
</View>
|
|
|
|
{/* 底部信息栏 */}
|
|
<View style={styles.footer}>
|
|
<View style={styles.footerLeft}>
|
|
<MaterialCommunityIcons
|
|
name="account-group-outline"
|
|
size={14}
|
|
color={colors.text.hint}
|
|
/>
|
|
<Text variant="caption" color={colors.text.hint} style={styles.footerText}>
|
|
{formatVoteCount(totalVotes)} 人参与
|
|
</Text>
|
|
</View>
|
|
|
|
{hasVoted && (
|
|
<TouchableOpacity
|
|
style={styles.unvoteButton}
|
|
onPress={handleUnvote}
|
|
disabled={isLoading}
|
|
>
|
|
<MaterialCommunityIcons
|
|
name="refresh"
|
|
size={14}
|
|
color={colors.text.hint}
|
|
/>
|
|
<Text variant="caption" color={colors.text.hint} style={styles.unvoteText}>
|
|
重选
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* 加载遮罩 */}
|
|
{isLoading && (
|
|
<View style={styles.loadingOverlay}>
|
|
<MaterialCommunityIcons
|
|
name="loading"
|
|
size={24}
|
|
color={colors.primary.main}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default VoteCard;
|