feat(Theme): enhance theming and UI consistency across components
- 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.
This commit is contained in:
@@ -3,12 +3,12 @@
|
||||
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
@@ -31,6 +31,174 @@ interface CommentItemProps {
|
||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||
}
|
||||
|
||||
function createCommentItemStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
minWidth: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
metaDot: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.hint,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
smallBadge: {
|
||||
paddingHorizontal: 2,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
smallBadgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
floorPlain: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
replyReference: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
commentContent: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 22,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
paddingVertical: 4,
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 4,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
subRepliesContainer: {
|
||||
marginTop: spacing.sm,
|
||||
marginLeft: 0,
|
||||
paddingLeft: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.divider,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
subReplyItemLast: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
subReplyBody: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
marginTop: 2,
|
||||
},
|
||||
subReplyContent: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
subReplyHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
subReplyAuthor: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
moreRepliesButton: {
|
||||
marginTop: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
replyToText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyToName: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subReplyActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
subActionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
subActionText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const CommentItem: React.FC<CommentItemProps> = ({
|
||||
comment,
|
||||
onUserPress,
|
||||
@@ -47,6 +215,8 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
onImagePress,
|
||||
currentUserId,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
@@ -471,170 +641,4 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
minWidth: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
metaDot: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.hint,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
smallBadge: {
|
||||
paddingHorizontal: 2,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
smallBadgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
floorPlain: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
replyReference: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
commentContent: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 22,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
paddingVertical: 4,
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 4,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
subRepliesContainer: {
|
||||
marginTop: spacing.sm,
|
||||
marginLeft: 0,
|
||||
paddingLeft: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.divider,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
subReplyItemLast: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
subReplyBody: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
marginTop: 2,
|
||||
},
|
||||
subReplyContent: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
subReplyHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
subReplyAuthor: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
moreRepliesButton: {
|
||||
marginTop: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
replyToText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyToName: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subReplyActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
subActionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
subActionText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default CommentItem;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { PostCardProps, PostCardAction } from './types';
|
||||
import { ImageGridItem, SmartImage } from '../../common';
|
||||
import Text from '../../common/Text';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../../theme';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||
import PostImages from './components/PostImages';
|
||||
import { useResponsive } from '../../../hooks/useResponsive';
|
||||
@@ -113,6 +113,270 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
||||
return true;
|
||||
}
|
||||
|
||||
function createPostCardStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
authorWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
authorMeta: {
|
||||
marginLeft: spacing.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
authorName: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
timeText: {
|
||||
color: colors.text.hint,
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 2,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
channelTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
maxWidth: 140,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
gap: 4,
|
||||
},
|
||||
channelTagText: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '600',
|
||||
flexShrink: 1,
|
||||
},
|
||||
pinnedTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `${colors.warning.light}22`,
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
gap: 2,
|
||||
},
|
||||
pinnedText: {
|
||||
color: colors.warning.main,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButton: {
|
||||
padding: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
content: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.md,
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: fontSizes.md * 1.45,
|
||||
},
|
||||
expandBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
expandText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
topCommentBox: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topCommentAuthor: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
},
|
||||
topCommentText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
flex: 1,
|
||||
},
|
||||
gridCover: {
|
||||
width: '100%',
|
||||
aspectRatio: 0.78,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionsLeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
channelTagAfterViews: {
|
||||
maxWidth: 130,
|
||||
flexShrink: 1,
|
||||
},
|
||||
viewsWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
viewsText: {
|
||||
color: colors.text.hint,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
activeActionText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
activeActionTextMerged: {
|
||||
color: colors.error.main,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
gridRootCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
gridNoImagePreview: {
|
||||
backgroundColor: colors.background.default,
|
||||
margin: 6,
|
||||
borderRadius: borderRadius.md,
|
||||
minHeight: 120,
|
||||
padding: 8,
|
||||
},
|
||||
gridNoImageText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
gridVoteTag: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: `${colors.primary.main}CC`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
gridVoteTagText: {
|
||||
color: colors.primary.contrast,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
gridTitleMain: {
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 8,
|
||||
minHeight: 44,
|
||||
},
|
||||
gridChannelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
gridChannelText: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
gridFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
gridUserArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
gridUsername: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
},
|
||||
gridLikeArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gridLikeCount: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCard 主组件
|
||||
* 仅支持新 API
|
||||
@@ -129,6 +393,8 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
style,
|
||||
} = normalizedProps;
|
||||
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createPostCardStyles(colors), [colors]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
@@ -444,266 +710,4 @@ PostCardInner.displayName = 'PostCard';
|
||||
|
||||
const PostCard = memo(PostCardInner, arePostCardPropsEqual);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
authorWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
authorMeta: {
|
||||
marginLeft: spacing.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
authorName: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
timeText: {
|
||||
color: colors.text.hint,
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 2,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
channelTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
maxWidth: 140,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
gap: 4,
|
||||
},
|
||||
channelTagText: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '600',
|
||||
flexShrink: 1,
|
||||
},
|
||||
pinnedTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `${colors.warning.light}22`,
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
gap: 2,
|
||||
},
|
||||
pinnedText: {
|
||||
color: colors.warning.main,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButton: {
|
||||
padding: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
content: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.md,
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: fontSizes.md * 1.45,
|
||||
},
|
||||
expandBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
expandText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
topCommentBox: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topCommentAuthor: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
},
|
||||
topCommentText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
flex: 1,
|
||||
},
|
||||
gridCover: {
|
||||
width: '100%',
|
||||
aspectRatio: 0.78,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionsLeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
channelTagAfterViews: {
|
||||
maxWidth: 130,
|
||||
flexShrink: 1,
|
||||
},
|
||||
viewsWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
viewsText: {
|
||||
color: colors.text.hint,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
activeActionText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
activeActionTextMerged: {
|
||||
color: colors.error.main,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
gridRootCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
gridNoImagePreview: {
|
||||
backgroundColor: colors.background.default,
|
||||
margin: 6,
|
||||
borderRadius: borderRadius.md,
|
||||
minHeight: 120,
|
||||
padding: 8,
|
||||
},
|
||||
gridNoImageText: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
gridVoteTag: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: `${colors.primary.main}CC`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
gridVoteTagText: {
|
||||
color: colors.primary.contrast,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
gridTitleMain: {
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 8,
|
||||
minHeight: 44,
|
||||
},
|
||||
gridChannelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
gridChannelText: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
gridFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
gridUserArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
gridUsername: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
},
|
||||
gridLikeArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gridLikeCount: {
|
||||
color: colors.text.secondary,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default PostCard;
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, borderRadius } from '../../../../theme';
|
||||
import { spacing, borderRadius } from '../../../../theme';
|
||||
import { useResponsive } from '../../../../hooks/useResponsive';
|
||||
import { ImageGrid, ImageGridItem } from '../../../common';
|
||||
import { PostImagesProps } from '../types';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -12,7 +12,7 @@ import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
@@ -21,14 +21,130 @@ interface QRCodeScannerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function createQrScannerStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xl,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
scanArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scanFrame: {
|
||||
width: width * 0.7,
|
||||
height: width * 0.7,
|
||||
position: 'relative',
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderColor: colors.primary.main,
|
||||
borderWidth: 3,
|
||||
},
|
||||
topLeft: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
topRight: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
bottomLeft: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
bottomRight: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
scanText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
flashButton: {
|
||||
padding: spacing.md,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
permissionContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
permissionText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
permissionButton: {
|
||||
marginTop: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
permissionButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const router = useRouter();
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 重置扫描状态,确保每次打开都能重新扫描
|
||||
setScanned(false);
|
||||
if (!permission?.granted) {
|
||||
requestPermission();
|
||||
@@ -39,16 +155,11 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
|
||||
if (scanned) return;
|
||||
setScanned(true);
|
||||
|
||||
// 先关闭扫描界面,类似微信的做法
|
||||
onClose();
|
||||
|
||||
// 解析二维码内容
|
||||
// 格式: carrotbbs://qrcode/login?session_id=xxx
|
||||
if (data.startsWith('carrotbbs://qrcode/login')) {
|
||||
const sessionId = extractSessionId(data);
|
||||
if (sessionId) {
|
||||
// 跳转到确认页面
|
||||
router.push(hrefs.hrefQrLoginConfirm(sessionId));
|
||||
} else {
|
||||
Alert.alert('无效的二维码', '无法识别该二维码');
|
||||
@@ -79,7 +190,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="#999" />
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
@@ -132,117 +243,4 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xl,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
scanArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scanFrame: {
|
||||
width: width * 0.7,
|
||||
height: width * 0.7,
|
||||
position: 'relative',
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderColor: colors.primary.main,
|
||||
borderWidth: 3,
|
||||
},
|
||||
topLeft: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
topRight: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
bottomLeft: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderRightWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
bottomRight: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
scanText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
flashButton: {
|
||||
padding: spacing.md,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
permissionContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
permissionText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
permissionButton: {
|
||||
marginTop: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
permissionButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default QRCodeScanner;
|
||||
export default QRCodeScanner;
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* 用于搜索内容
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
@@ -18,6 +18,63 @@ interface SearchBarProps {
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function createSearchBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: colors.primary.main,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -27,6 +84,8 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -59,7 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus={autoFocus}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
@@ -69,73 +127,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
style={styles.clearButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={14}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
<MaterialCommunityIcons name="close" size={14} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E7E7E7',
|
||||
// 减少常态阴影,避免看起来像"黑框"
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
containerFocused: {
|
||||
// 焦点时使用更柔和的边框颜色
|
||||
borderColor: colors.primary.main,
|
||||
// 不添加额外的阴影,保持简洁
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchBar;
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* 采用卡片式设计,符合胡萝卜BBS整体风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Animated } from 'react-native';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
@@ -23,9 +23,9 @@ interface SystemMessageItemProps {
|
||||
index?: number; // 用于交错动画
|
||||
}
|
||||
|
||||
// 系统消息类型到图标和颜色的映射
|
||||
const getSystemMessageIcon = (
|
||||
systemType: SystemMessageType
|
||||
systemType: SystemMessageType,
|
||||
colors: AppColors
|
||||
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => {
|
||||
switch (systemType) {
|
||||
case 'like_post':
|
||||
@@ -119,6 +119,155 @@ const getSystemMessageIcon = (
|
||||
}
|
||||
};
|
||||
|
||||
function createSystemMessageStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
...shadows.sm,
|
||||
},
|
||||
unreadContainer: {
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
},
|
||||
unreadIndicator: {
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
top: '50%',
|
||||
marginTop: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
typeIconBadge: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
titleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
unreadTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
marginLeft: 2,
|
||||
},
|
||||
time: {
|
||||
fontSize: fontSizes.sm,
|
||||
flexShrink: 0,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: `${colors.error.main}55`,
|
||||
backgroundColor: `${colors.error.main}18`,
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: `${colors.success.main}55`,
|
||||
backgroundColor: `${colors.success.main}18`,
|
||||
},
|
||||
actionBtnText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 获取系统消息标题
|
||||
const getSystemMessageTitle = (message: SystemMessageResponse): string => {
|
||||
const { system_type, extra_data } = message;
|
||||
@@ -177,6 +326,9 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
requestActionLoading = false,
|
||||
index = 0,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
@@ -189,7 +341,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type);
|
||||
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
|
||||
const title = getSystemMessageTitle(message);
|
||||
const { extra_data } = message;
|
||||
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
|
||||
@@ -212,17 +364,17 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
const getStatusBadge = () => {
|
||||
if (requestStatus === 'accepted') {
|
||||
return (
|
||||
<View style={[styles.statusBadge, { backgroundColor: '#E8F5E9' }]}>
|
||||
<MaterialCommunityIcons name="check" size={10} color="#4CAF50" />
|
||||
<Text variant="caption" color="#4CAF50" style={styles.statusText}>已同意</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${colors.success.main}22` }]}>
|
||||
<MaterialCommunityIcons name="check" size={10} color={colors.success.main} />
|
||||
<Text variant="caption" color={colors.success.main} style={styles.statusText}>已同意</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (requestStatus === 'rejected') {
|
||||
return (
|
||||
<View style={[styles.statusBadge, { backgroundColor: '#FFEBEE' }]}>
|
||||
<MaterialCommunityIcons name="close" size={10} color="#F44336" />
|
||||
<Text variant="caption" color="#F44336" style={styles.statusText}>已拒绝</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${colors.error.main}22` }]}>
|
||||
<MaterialCommunityIcons name="close" size={10} color={colors.error.main} />
|
||||
<Text variant="caption" color={colors.error.main} style={styles.statusText}>已拒绝</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -328,16 +480,16 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
onPress={() => onRequestAction?.(false)}
|
||||
disabled={requestActionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons name="close" size={14} color="#F44336" />
|
||||
<Text variant="caption" color="#F44336" style={styles.actionBtnText}>拒绝</Text>
|
||||
<MaterialCommunityIcons name="close" size={14} color={colors.error.main} />
|
||||
<Text variant="caption" color={colors.error.main} style={styles.actionBtnText}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.approveBtn]}
|
||||
onPress={() => onRequestAction?.(true)}
|
||||
disabled={requestActionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons name="check" size={14} color="#4CAF50" />
|
||||
<Text variant="caption" color="#4CAF50" style={styles.actionBtnText}>同意</Text>
|
||||
<MaterialCommunityIcons name="check" size={14} color={colors.success.main} />
|
||||
<Text variant="caption" color={colors.success.main} style={styles.actionBtnText}>同意</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -353,151 +505,4 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
...shadows.sm,
|
||||
},
|
||||
unreadContainer: {
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
},
|
||||
unreadIndicator: {
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
top: '50%',
|
||||
marginTop: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
typeIconBadge: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
titleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
unreadTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
marginLeft: 2,
|
||||
},
|
||||
time: {
|
||||
fontSize: fontSizes.sm,
|
||||
flexShrink: 0,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: '#FFCDD2',
|
||||
backgroundColor: '#FFEBEE',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: '#C8E6C9',
|
||||
backgroundColor: '#E8F5E9',
|
||||
},
|
||||
actionBtnText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default SystemMessageItem;
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* 新增胶囊式、分段式等现代设计风格
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ScrollView, Animated, ViewStyle, StyleProp } from 'react-native';
|
||||
import React, { ReactNode, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
||||
@@ -20,10 +20,168 @@ interface TabBarProps {
|
||||
rightContent?: ReactNode;
|
||||
variant?: TabBarVariant;
|
||||
icons?: string[];
|
||||
/** 合并到最外层容器,用于按页面微调外边距等 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
function createTabBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
scrollableContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
flex: 1,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
activeTab: {},
|
||||
tabText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
activeIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
rightContent: {
|
||||
paddingLeft: spacing.sm,
|
||||
},
|
||||
pillContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pillTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
pillTabActive: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
pillTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentedContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.xs,
|
||||
marginHorizontal: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
segmentedTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
segmentedTabActive: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
segmentedTabFirst: {
|
||||
borderTopLeftRadius: borderRadius.md,
|
||||
borderBottomLeftRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabLast: {
|
||||
borderTopRightRadius: borderRadius.md,
|
||||
borderBottomRightRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentedTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentedTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
padding: spacing.xs,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
position: 'relative',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modernTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernTabText: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
modernTabIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const TabBar: React.FC<TabBarProps> = ({
|
||||
tabs,
|
||||
activeIndex,
|
||||
@@ -34,6 +192,9 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
icons,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createTabBarStyles(colors), [colors]);
|
||||
|
||||
const renderTabs = () => {
|
||||
return tabs.map((tab, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
@@ -112,11 +273,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
style={styles.segmentedTabIcon}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
style={styles.segmentedTabText}
|
||||
>
|
||||
<Text variant="body" color={isActive ? colors.primary.main : colors.text.secondary} style={styles.segmentedTabText}>
|
||||
{tab}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -124,7 +281,6 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// default variant
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
@@ -161,11 +317,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
if (scrollable) {
|
||||
return (
|
||||
<View style={[getContainerStyle(), style]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollableContainer}
|
||||
>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollableContainer}>
|
||||
{renderTabs()}
|
||||
</ScrollView>
|
||||
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
||||
@@ -181,170 +333,4 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Default variant
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
scrollableContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
flex: 1,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
activeTab: {
|
||||
// 激活状态样式
|
||||
},
|
||||
tabText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
activeIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
rightContent: {
|
||||
paddingLeft: spacing.sm,
|
||||
},
|
||||
|
||||
// Pill variant
|
||||
pillContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pillTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
pillTabActive: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
pillTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Segmented variant
|
||||
segmentedContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.xs,
|
||||
marginHorizontal: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
segmentedTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
segmentedTabActive: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
segmentedTabFirst: {
|
||||
borderTopLeftRadius: borderRadius.md,
|
||||
borderBottomLeftRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabLast: {
|
||||
borderTopRightRadius: borderRadius.md,
|
||||
borderBottomRightRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentedTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentedTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
|
||||
// Modern variant - 现代化标签栏
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
padding: spacing.xs,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
position: 'relative',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15', // 10% opacity
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modernTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernTabText: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
modernTabIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
});
|
||||
|
||||
export default TabBar;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 在宽屏下显示更大的头像和封面
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Image,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Button from '../common/Button';
|
||||
@@ -49,7 +49,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
onFollowersPress,
|
||||
onAvatarPress,
|
||||
}) => {
|
||||
// 响应式布局
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
|
||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||
|
||||
// 格式化数字
|
||||
@@ -317,7 +318,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
function createUserProfileHeaderStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
@@ -533,7 +535,8 @@ const styles = StyleSheet.create({
|
||||
alignSelf: 'center',
|
||||
padding: spacing.sm,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 React.memo 避免不必要的重新渲染
|
||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 风格与现代整体UI保持一致
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { VoteOptionDTO } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
|
||||
@@ -28,6 +28,146 @@ interface VoteCardProps {
|
||||
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,
|
||||
@@ -38,7 +178,8 @@ const VoteCard: React.FC<VoteCardProps> = ({
|
||||
isLoading = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
// 动画值
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createVoteCardStyles(colors), [colors]);
|
||||
const progressAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -151,7 +292,7 @@ const VoteCard: React.FC<VoteCardProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]);
|
||||
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]);
|
||||
|
||||
// 排序后的选项(已投票的排在前面)
|
||||
const sortedOptions = React.useMemo(() => {
|
||||
@@ -229,142 +370,4 @@ const VoteCard: React.FC<VoteCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = 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,
|
||||
},
|
||||
});
|
||||
|
||||
export default VoteCard;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 用于创建帖子时编辑投票选项
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
interface VoteEditorProps {
|
||||
@@ -24,6 +24,86 @@ interface VoteEditorProps {
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
function createVoteEditorStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsContainer: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionIndex: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.disabled,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionInput: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
height: 44,
|
||||
},
|
||||
removeButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
removeButtonPlaceholder: {
|
||||
width: 28,
|
||||
},
|
||||
addOptionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
addOptionText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
hintContainer: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const VoteEditor: React.FC<VoteEditorProps> = ({
|
||||
options,
|
||||
onAddOption,
|
||||
@@ -33,6 +113,8 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
|
||||
minOptions = 2,
|
||||
maxLength = 50,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createVoteEditorStyles(colors), [colors]);
|
||||
const validOptionsCount = options.filter(opt => opt.trim() !== '').length;
|
||||
const canAddOption = options.length < maxOptions;
|
||||
const canRemoveOption = options.length > minOptions;
|
||||
@@ -122,82 +204,4 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsContainer: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionIndex: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.disabled,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionInput: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
height: 44,
|
||||
},
|
||||
removeButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
removeButtonPlaceholder: {
|
||||
width: 28,
|
||||
},
|
||||
addOptionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
addOptionText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
hintContainer: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default VoteEditor;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
* 用于帖子列表中显示投票预览,类似微博风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
interface VotePreviewProps {
|
||||
@@ -19,11 +19,51 @@ interface VotePreviewProps {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
function createVotePreviewStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light + '30',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const VotePreview: React.FC<VotePreviewProps> = ({
|
||||
totalVotes = 0,
|
||||
optionsCount = 0,
|
||||
onPress,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createVotePreviewStyles(colors), [colors]);
|
||||
|
||||
// 格式化票数
|
||||
const formatVoteCount = (count: number): string => {
|
||||
if (count >= 10000) {
|
||||
@@ -71,39 +111,4 @@ const VotePreview: React.FC<VotePreviewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light + '30',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
});
|
||||
|
||||
export default VotePreview;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { borderRadius, colors, spacing } from '../../theme';
|
||||
import { borderRadius, spacing, useAppColors } from '../../theme';
|
||||
|
||||
type AppBackButtonIcon = 'arrow-left' | 'close';
|
||||
|
||||
@@ -20,11 +20,15 @@ const AppBackButton: React.FC<AppBackButtonProps> = ({
|
||||
onPress,
|
||||
icon = 'arrow-left',
|
||||
style,
|
||||
iconColor = colors.text.primary,
|
||||
backgroundColor = colors.background.paper,
|
||||
iconColor: iconColorProp,
|
||||
backgroundColor: backgroundColorProp,
|
||||
hitSlop = 8,
|
||||
testID,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const iconColor = iconColorProp ?? colors.text.primary;
|
||||
const backgroundColor = backgroundColorProp ?? colors.background.paper;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
|
||||
@@ -5,10 +5,98 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||
import { borderRadius, colors, shadows, spacing } from '../../theme';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
function createDialogHostStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.36)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
iconHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
marginTop: spacing.md,
|
||||
color: colors.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
height: 46,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}28`,
|
||||
backgroundColor: colors.background.paper,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: `${colors.error.main}18`,
|
||||
borderColor: `${colors.error.main}40`,
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
primaryText: {
|
||||
color: colors.text.inverse,
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
destructiveText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const AppDialogHost: React.FC = () => {
|
||||
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createDialogHostStyles(colors), [colors]);
|
||||
|
||||
useEffect(() => {
|
||||
const unbind = bindDialogListener((payload) => {
|
||||
@@ -19,7 +107,7 @@ const AppDialogHost: React.FC = () => {
|
||||
|
||||
const actions = useMemo<AlertButton[]>(() => {
|
||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||
return dialog.actions.slice(0, 3);
|
||||
return dialog.actions;
|
||||
}, [dialog]);
|
||||
|
||||
const onClose = () => {
|
||||
@@ -101,88 +189,4 @@ const AppDialogHost: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.36)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
iconHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
marginTop: spacing.md,
|
||||
color: colors.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
height: 46,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}28`,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: '#FEECEC',
|
||||
borderColor: '#FCD4D1',
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
destructiveText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppDialogHost;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
|
||||
import { borderRadius, colors, shadows, spacing } from '../../theme';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
interface PromptState extends PromptPayload {
|
||||
id: number;
|
||||
@@ -12,29 +12,96 @@ interface PromptState extends PromptPayload {
|
||||
|
||||
const DEFAULT_DURATION = 2200;
|
||||
|
||||
const styleMap: Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }> = {
|
||||
info: { backgroundColor: '#FFFFFF', icon: 'information-outline' },
|
||||
success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' },
|
||||
warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' },
|
||||
error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' },
|
||||
};
|
||||
|
||||
const iconColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.dark,
|
||||
error: colors.error.main,
|
||||
};
|
||||
|
||||
const accentColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.main,
|
||||
error: colors.error.main,
|
||||
};
|
||||
function createPromptBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 9999,
|
||||
},
|
||||
card: {
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}22`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
...shadows.lg,
|
||||
},
|
||||
accentBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
textWrap: {
|
||||
flex: 1,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
marginBottom: 2,
|
||||
},
|
||||
message: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const AppPromptBar: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createPromptBarStyles(colors), [colors]);
|
||||
|
||||
const styleMap = useMemo<
|
||||
Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }>
|
||||
>(
|
||||
() => ({
|
||||
info: { backgroundColor: colors.background.paper, icon: 'information-outline' },
|
||||
success: { backgroundColor: colors.background.paper, icon: 'check-circle-outline' },
|
||||
warning: { backgroundColor: colors.background.paper, icon: 'alert-outline' },
|
||||
error: { backgroundColor: colors.background.paper, icon: 'alert-circle-outline' },
|
||||
}),
|
||||
[colors.background.paper],
|
||||
);
|
||||
|
||||
const iconColorMap = useMemo(
|
||||
() => ({
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.dark,
|
||||
error: colors.error.main,
|
||||
}),
|
||||
[colors.primary.main, colors.success.main, colors.warning.dark, colors.error.main],
|
||||
);
|
||||
|
||||
const accentColorMap = useMemo(
|
||||
() => ({
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.main,
|
||||
error: colors.error.main,
|
||||
}),
|
||||
[colors.primary.main, colors.success.main, colors.warning.main, colors.error.main],
|
||||
);
|
||||
|
||||
const [prompt, setPrompt] = useState<PromptState | null>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animation = useRef(new Animated.Value(0)).current;
|
||||
@@ -131,55 +198,4 @@ const AppPromptBar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 9999,
|
||||
},
|
||||
card: {
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}22`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
...shadows.lg,
|
||||
},
|
||||
accentBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
textWrap: {
|
||||
flex: 1,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
marginBottom: 2,
|
||||
},
|
||||
message: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppPromptBar;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
import { borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type AvatarSource = string | { uri: string } | number | null;
|
||||
@@ -27,29 +27,50 @@ interface AvatarProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createAvatarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
placeholder: {
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({
|
||||
source,
|
||||
size = 40,
|
||||
name,
|
||||
onPress,
|
||||
showBadge = false,
|
||||
badgeColor = colors.success.main,
|
||||
badgeColor,
|
||||
style,
|
||||
}) => {
|
||||
// 获取首字母
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createAvatarStyles(colors), [colors]);
|
||||
const resolvedBadgeColor = badgeColor ?? colors.success.main;
|
||||
|
||||
const getInitial = (): string => {
|
||||
if (!name) return '?';
|
||||
const firstChar = name.charAt(0).toUpperCase();
|
||||
// 中文字符
|
||||
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
|
||||
return firstChar;
|
||||
}
|
||||
return firstChar;
|
||||
};
|
||||
|
||||
// 渲染头像内容
|
||||
const renderAvatarContent = () => {
|
||||
// 如果有图片源
|
||||
if (source) {
|
||||
const imageSource =
|
||||
typeof source === 'string' ? { uri: source } : source;
|
||||
@@ -72,7 +93,6 @@ const Avatar: React.FC<AvatarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 显示首字母
|
||||
const fontSize = size / 2;
|
||||
return (
|
||||
<View
|
||||
@@ -100,7 +120,7 @@ const Avatar: React.FC<AvatarProps> = ({
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
backgroundColor: badgeColor,
|
||||
backgroundColor: resolvedBadgeColor,
|
||||
width: size * 0.3,
|
||||
height: size * 0.3,
|
||||
borderRadius: (size * 0.3) / 2,
|
||||
@@ -124,23 +144,4 @@ const Avatar: React.FC<AvatarProps> = ({
|
||||
return avatarContainer;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
placeholder: {
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
});
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 支持多种变体、尺寸、加载状态和图标
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes, shadows } from '../../theme';
|
||||
import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
|
||||
@@ -27,12 +27,86 @@ interface ButtonProps {
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: string; // MaterialCommunityIcons name
|
||||
icon?: string;
|
||||
iconPosition?: IconPosition;
|
||||
fullWidth?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createButtonStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
size_sm: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 32,
|
||||
},
|
||||
size_md: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 40,
|
||||
},
|
||||
size_lg: {
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.xl,
|
||||
minHeight: 48,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.secondary.main,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: 'transparent',
|
||||
elevation: 0,
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
disabled: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
borderColor: colors.background.disabled,
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
textBase: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
textSize_sm: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
textSize_md: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
textSize_lg: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
iconLeft: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
iconRight: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
@@ -45,11 +119,12 @@ const Button: React.FC<ButtonProps> = ({
|
||||
fullWidth = false,
|
||||
style,
|
||||
}) => {
|
||||
// 获取按钮样式
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createButtonStyles(colors), [colors]);
|
||||
|
||||
const getButtonStyle = (): ViewStyle[] => {
|
||||
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
|
||||
|
||||
// 变体样式
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
baseStyle.push(styles.primary);
|
||||
@@ -68,12 +143,10 @@ const Button: React.FC<ButtonProps> = ({
|
||||
break;
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
if (disabled || loading) {
|
||||
baseStyle.push(styles.disabled);
|
||||
}
|
||||
|
||||
// 全宽度
|
||||
if (fullWidth) {
|
||||
baseStyle.push(styles.fullWidth);
|
||||
}
|
||||
@@ -81,7 +154,6 @@ const Button: React.FC<ButtonProps> = ({
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取文本样式
|
||||
const getTextStyle = (): TextStyle => {
|
||||
const baseStyle: TextStyle = {
|
||||
...styles.textBase,
|
||||
@@ -109,7 +181,6 @@ const Button: React.FC<ButtonProps> = ({
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取图标大小
|
||||
const getIconSize = (): number => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
@@ -121,7 +192,6 @@ const Button: React.FC<ButtonProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图标颜色
|
||||
const getIconColor = (): string => {
|
||||
if (disabled || loading) {
|
||||
return colors.text.disabled;
|
||||
@@ -150,9 +220,9 @@ const Button: React.FC<ButtonProps> = ({
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={variant === 'outline' || variant === 'text'
|
||||
? colors.primary.main
|
||||
: colors.text.inverse}
|
||||
color={
|
||||
variant === 'outline' || variant === 'text' ? colors.primary.main : colors.text.inverse
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
@@ -179,82 +249,4 @@ const Button: React.FC<ButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 尺寸样式
|
||||
size_sm: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 32,
|
||||
},
|
||||
size_md: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 40,
|
||||
},
|
||||
size_lg: {
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.xl,
|
||||
minHeight: 48,
|
||||
},
|
||||
// 变体样式
|
||||
primary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.secondary.main,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: 'transparent',
|
||||
elevation: 0,
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
// 禁用状态
|
||||
disabled: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
borderColor: colors.background.disabled,
|
||||
},
|
||||
// 全宽度
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
// 文本样式
|
||||
textBase: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
textSize_sm: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
textSize_md: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
textSize_lg: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
// 图标样式
|
||||
iconLeft: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
iconRight: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* 白色背景、圆角、阴影,支持点击
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, borderRadius, spacing, shadows } from '../../theme';
|
||||
import { borderRadius, spacing, shadows, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
type ShadowSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
@@ -13,10 +13,20 @@ interface CardProps {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
padding?: number;
|
||||
shadow?: ShadowSize;
|
||||
shadow?: ShadowSize | 'none';
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createCardStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
@@ -24,13 +34,11 @@ const Card: React.FC<CardProps> = ({
|
||||
shadow = 'none',
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createCardStyles(colors), [colors]);
|
||||
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
|
||||
|
||||
const cardStyle = [
|
||||
styles.card,
|
||||
{ padding },
|
||||
shadowStyle,
|
||||
].filter(Boolean);
|
||||
|
||||
const cardStyle = [styles.card, { padding }, shadowStyle].filter(Boolean);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
@@ -47,12 +55,4 @@ const Card: React.FC<CardProps> = ({
|
||||
return <View style={[...cardStyle, style]}>{children}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default Card;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { spacing, useAppColors } from '../../theme';
|
||||
|
||||
interface DividerProps {
|
||||
margin?: number;
|
||||
@@ -13,16 +13,14 @@ interface DividerProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Divider: React.FC<DividerProps> = ({
|
||||
margin = spacing.lg,
|
||||
color = colors.divider,
|
||||
style,
|
||||
}) => {
|
||||
const Divider: React.FC<DividerProps> = ({ margin = spacing.lg, color, style }) => {
|
||||
const colors = useAppColors();
|
||||
const lineColor = color ?? colors.divider;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.divider,
|
||||
{ marginVertical: margin, backgroundColor: color },
|
||||
{ marginVertical: margin, backgroundColor: lineColor },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* 显示空数据时的占位界面,采用现代插图风格设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
import Button from './Button';
|
||||
|
||||
@@ -20,6 +20,109 @@ interface EmptyStateProps {
|
||||
variant?: 'default' | 'modern' | 'compact';
|
||||
}
|
||||
|
||||
function createEmptyStateStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
minWidth: 120,
|
||||
},
|
||||
modernContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
minHeight: 280,
|
||||
},
|
||||
illustrationContainer: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
iconBackground: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
modernIcon: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
top: 5,
|
||||
right: 10,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main + '20',
|
||||
bottom: 15,
|
||||
left: 5,
|
||||
},
|
||||
modernTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
modernDescription: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
maxWidth: 280,
|
||||
},
|
||||
modernButton: {
|
||||
minWidth: 140,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
compactIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
compactTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon = 'folder-open-outline',
|
||||
title,
|
||||
@@ -29,7 +132,9 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
style,
|
||||
variant = 'modern',
|
||||
}) => {
|
||||
// 现代风格空状态
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createEmptyStateStyles(colors), [colors]);
|
||||
|
||||
if (variant === 'modern') {
|
||||
return (
|
||||
<View style={[styles.modernContainer, style]}>
|
||||
@@ -45,19 +150,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
</View>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.primary}
|
||||
style={styles.modernTitle}
|
||||
>
|
||||
<Text variant="h3" color={colors.text.primary} style={styles.modernTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.modernDescription}
|
||||
>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.modernDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
@@ -74,7 +171,6 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑风格
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<View style={[styles.compactContainer, style]}>
|
||||
@@ -84,18 +180,13 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
color={colors.text.disabled}
|
||||
style={styles.compactIcon}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.compactTitle}
|
||||
>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.compactTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认风格
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<MaterialCommunityIcons
|
||||
@@ -104,19 +195,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
color={colors.text.disabled}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.secondary}
|
||||
style={styles.title}
|
||||
>
|
||||
<Text variant="h3" color={colors.text.secondary} style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.description}
|
||||
>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.description}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
@@ -133,110 +216,4 @@ const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// 默认风格
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
minWidth: 120,
|
||||
},
|
||||
|
||||
// 现代风格
|
||||
modernContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
minHeight: 280,
|
||||
},
|
||||
illustrationContainer: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
iconBackground: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
modernIcon: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
top: 5,
|
||||
right: 10,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main + '20',
|
||||
bottom: 15,
|
||||
left: 5,
|
||||
},
|
||||
modernTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
modernDescription: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
maxWidth: 280,
|
||||
},
|
||||
modernButton: {
|
||||
minWidth: 140,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
|
||||
// 紧凑风格
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
compactIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
compactTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmptyState;
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 支持预览图优化
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useCallback, useState, useEffect, createContext, useContext } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SmartImage } from './SmartImage';
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
@@ -101,6 +101,89 @@ const calculateGridDimensions = (
|
||||
};
|
||||
};
|
||||
|
||||
function createImageGridStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
fullSize: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
singleContainer: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
horizontalContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
horizontalItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
gridItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '48%',
|
||||
},
|
||||
gridItem3: {
|
||||
width: '31%',
|
||||
},
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
masonryColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
masonryItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
moreOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
moreText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '500',
|
||||
},
|
||||
compactContainer: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
compactGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
compactItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ImageGridStyles = ReturnType<typeof createImageGridStyles>;
|
||||
const ImageGridStylesContext = createContext<ImageGridStyles | null>(null);
|
||||
|
||||
function useImageGridStyles(): ImageGridStyles {
|
||||
const ctx = useContext(ImageGridStylesContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useImageGridStyles must be used within ImageGrid or CompactImageGrid');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
|
||||
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
|
||||
|
||||
@@ -123,6 +206,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
usePreview,
|
||||
displayMode,
|
||||
}) => {
|
||||
const styles = useImageGridStyles();
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const mainUri = image.uri || image.url || '';
|
||||
const thumbnailUri =
|
||||
@@ -245,7 +329,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
displayMode = 'list',
|
||||
usePreview = true,
|
||||
}) => {
|
||||
// 通过 onLayout 拿到容器实际宽度
|
||||
const colors = useAppColors();
|
||||
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// 过滤有效图片 - 支持 uri 或 url 字段
|
||||
@@ -303,7 +388,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
// 渲染横向双图
|
||||
const renderHorizontal = () => {
|
||||
return (
|
||||
<View style={[styles.horizontalContainer, { gap }]}>
|
||||
<View style={[gridStyles.horizontalContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => {
|
||||
const previewUrl = usePreview && displayMode
|
||||
? getPreviewImageUrl(image as any, displayMode)
|
||||
@@ -314,7 +399,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.horizontalItem,
|
||||
gridStyles.horizontalItem,
|
||||
{
|
||||
flex: 1,
|
||||
aspectRatio: 1,
|
||||
@@ -324,7 +409,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
@@ -338,7 +423,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
// 渲染网格布局
|
||||
const renderGrid = () => {
|
||||
return (
|
||||
<View style={[styles.gridContainer, { gap }]}>
|
||||
<View style={[gridStyles.gridContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => {
|
||||
const isLastVisible = index === displayImages.length - 1;
|
||||
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
|
||||
@@ -352,8 +437,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
|
||||
gridStyles.gridItem,
|
||||
gridColumns === 3 ? gridStyles.gridItem3 : gridStyles.gridItem2,
|
||||
{
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
@@ -361,13 +446,13 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{showOverlay && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{remainingCount}</Text>
|
||||
<View style={gridStyles.moreOverlay}>
|
||||
<Text style={gridStyles.moreText}>+{remainingCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -397,7 +482,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
|
||||
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
|
||||
return (
|
||||
<View style={[styles.masonryColumn, { gap }]}>
|
||||
<View style={[gridStyles.masonryColumn, { gap }]}>
|
||||
{columnImages.map((image, index) => {
|
||||
const actualIndex = columnIndex + index * 2;
|
||||
const aspectRatio = image.width && image.height
|
||||
@@ -414,7 +499,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
key={image.id || actualIndex}
|
||||
onPress={() => handleImagePress(actualIndex)}
|
||||
style={[
|
||||
styles.masonryItem,
|
||||
gridStyles.masonryItem,
|
||||
{
|
||||
width: itemWidth,
|
||||
height: Math.max(height, itemWidth * 0.7),
|
||||
@@ -424,7 +509,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
@@ -436,7 +521,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.masonryContainer, { gap }]}>
|
||||
<View style={[gridStyles.masonryContainer, { gap }]}>
|
||||
{renderColumn(leftColumn, 0)}
|
||||
{renderColumn(rightColumn, 1)}
|
||||
</View>
|
||||
@@ -464,13 +549,15 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, style]}
|
||||
testID={testID}
|
||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
<ImageGridStylesContext.Provider value={gridStyles}>
|
||||
<View
|
||||
style={[gridStyles.container, style]}
|
||||
testID={testID}
|
||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</ImageGridStylesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -490,6 +577,8 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
borderRadius: borderRadiusValue = borderRadius.sm,
|
||||
...props
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
|
||||
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
|
||||
|
||||
const renderCompactGrid = () => {
|
||||
@@ -506,7 +595,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
<Pressable
|
||||
onPress={() => props.onImagePress?.(images, 0)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
gridStyles.compactItem,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -516,7 +605,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
@@ -529,13 +618,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
|
||||
|
||||
return (
|
||||
<View style={[styles.compactGrid, { gap }]}>
|
||||
<View style={[gridStyles.compactGrid, { gap }]}>
|
||||
{images.slice(0, 6).map((image, index) => (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => props.onImagePress?.(images, index)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
gridStyles.compactItem,
|
||||
{
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
@@ -545,13 +634,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{index === 5 && images.length > 6 && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{images.length - 6}</Text>
|
||||
<View style={gridStyles.moreOverlay}>
|
||||
<Text style={gridStyles.moreText}>+{images.length - 6}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -560,86 +649,11 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
|
||||
return (
|
||||
<ImageGridStylesContext.Provider value={gridStyles}>
|
||||
<View style={gridStyles.compactContainer}>{renderCompactGrid()}</View>
|
||||
</ImageGridStylesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
fullSize: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 单图样式
|
||||
singleContainer: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 横向布局样式
|
||||
horizontalContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
horizontalItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 网格布局样式
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
gridItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '48%', // 2列布局,每列约48%宽度,留有余量避免换行
|
||||
},
|
||||
gridItem3: {
|
||||
width: '31%', // 3列布局,每列约31%宽度,留有余量避免换行
|
||||
},
|
||||
// 瀑布流样式
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
masonryColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
masonryItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 更多遮罩 - 类似微博的灰色蒙版
|
||||
moreOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
moreText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 紧凑模式样式
|
||||
compactContainer: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
compactGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
compactItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
});
|
||||
|
||||
// 导入字体大小
|
||||
import { fontSizes } from '../../theme';
|
||||
|
||||
export default ImageGrid;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 支持标签、错误提示、图标、多行输入等
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes } from '../../theme';
|
||||
import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
interface InputProps {
|
||||
@@ -36,6 +36,46 @@ interface InputProps {
|
||||
autoCorrect?: boolean;
|
||||
}
|
||||
|
||||
function createInputStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 44,
|
||||
},
|
||||
multilineInput: {
|
||||
textAlignVertical: 'top',
|
||||
minHeight: 100,
|
||||
},
|
||||
leftIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rightIcon: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
error: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Input: React.FC<InputProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -55,6 +95,8 @@ const Input: React.FC<InputProps> = ({
|
||||
keyboardType = 'default',
|
||||
autoCorrect = true,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createInputStyles(colors), [colors]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const getBorderColor = () => {
|
||||
@@ -87,11 +129,7 @@ const Input: React.FC<InputProps> = ({
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
multiline && styles.multilineInput,
|
||||
inputStyle,
|
||||
]}
|
||||
style={[styles.input, multiline && styles.multilineInput, inputStyle]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
@@ -105,15 +143,11 @@ const Input: React.FC<InputProps> = ({
|
||||
autoCapitalize={autoCapitalize}
|
||||
keyboardType={keyboardType}
|
||||
autoCorrect={autoCorrect}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<TouchableOpacity
|
||||
onPress={onRightIconPress}
|
||||
disabled={!onRightIconPress}
|
||||
>
|
||||
<TouchableOpacity onPress={onRightIconPress} disabled={!onRightIconPress}>
|
||||
<MaterialCommunityIcons
|
||||
name={rightIcon as any}
|
||||
size={20}
|
||||
@@ -132,43 +166,4 @@ const Input: React.FC<InputProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
// 减少非焦点状态的边框明显度
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 44,
|
||||
},
|
||||
multilineInput: {
|
||||
textAlignVertical: 'top',
|
||||
minHeight: 100,
|
||||
},
|
||||
leftIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rightIcon: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
error: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default Input;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* 支持不同尺寸、全屏模式
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors } from '../../theme';
|
||||
import { useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
type LoadingSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
@@ -16,12 +16,32 @@ interface LoadingProps {
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function createLoadingStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fullScreen: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const Loading: React.FC<LoadingProps> = ({
|
||||
size = 'md',
|
||||
color = colors.primary.main,
|
||||
color,
|
||||
fullScreen = false,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createLoadingStyles(colors), [colors]);
|
||||
const indicatorColor = color ?? colors.primary.main;
|
||||
|
||||
const getSize = (): 'small' | 'large' | undefined => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
@@ -36,30 +56,16 @@ const Loading: React.FC<LoadingProps> = ({
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<View style={[styles.fullScreen, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
<ActivityIndicator size={getSize()} color={indicatorColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
<ActivityIndicator size={getSize()} color={indicatorColor} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fullScreen: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
|
||||
export default Loading;
|
||||
|
||||
137
src/components/common/SearchBar.tsx
Normal file
137
src/components/common/SearchBar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* SearchBar 搜索栏组件
|
||||
* 用于搜索内容
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function createSearchBarStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: colors.primary.main,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
placeholder = '搜索...',
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isFocused && styles.containerFocused]}>
|
||||
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
||||
<MaterialCommunityIcons
|
||||
name="magnify"
|
||||
size={18}
|
||||
color={isFocused ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={onSubmit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus={autoFocus}
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onChangeText('')}
|
||||
style={styles.clearButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="close" size={14} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -4,7 +4,7 @@
|
||||
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -17,7 +17,36 @@ import {
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
import { borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
function createSmartImageStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
image: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
variantImage: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 8,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: `${colors.background.paper}E6`,
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 12,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: `${colors.text.primary}0D`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 图片加载状态
|
||||
export type ImageLoadState = 'loading' | 'success' | 'error';
|
||||
@@ -91,6 +120,8 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
lazyRootMargin = '160px',
|
||||
cachePolicy = 'memory-disk',
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSmartImageStyles(colors), [colors]);
|
||||
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
||||
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
|
||||
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
|
||||
@@ -244,7 +275,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
<View
|
||||
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
|
||||
collapsable={false}
|
||||
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
|
||||
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.default }]}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
@@ -328,36 +359,15 @@ export const VariantImage: React.FC<ImageVariantProps> = ({
|
||||
<SmartImage
|
||||
{...props}
|
||||
style={finalStyle}
|
||||
imageStyle={[styles.variantImage, imageStyle]}
|
||||
imageStyle={[variantStyles.variantImage, imageStyle]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
const variantStyles = StyleSheet.create({
|
||||
variantImage: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 8,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 12,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
export default SmartImage;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||
import { colors, fontSizes } from '../../theme';
|
||||
import { useAppColors, fontSizes } from '../../theme';
|
||||
|
||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||
|
||||
@@ -60,6 +60,7 @@ const Text: React.FC<CustomTextProps> = ({
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const textStyle = [
|
||||
styles.base,
|
||||
variantStyles[variant],
|
||||
|
||||
Reference in New Issue
Block a user