feat(Theme): enhance theming and UI consistency across components
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m15s
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Successful in 1h3m22s

- 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:
lafay
2026-03-25 05:16:54 +08:00
parent 90d834695f
commit 4ee3079b9f
86 changed files with 6777 additions and 5890 deletions

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
]}
/>

View File

@@ -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;

View File

@@ -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');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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],