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

@@ -5,7 +5,7 @@
"version": "1.0.11", "version": "1.0.11",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "automatic",
"scheme": "carrotbbs", "scheme": "carrotbbs",
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./assets/splash-icon.png",

View File

@@ -4,7 +4,7 @@ import { Tabs, usePathname } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, shadows } from '../../../src/theme'; import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores'; import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
@@ -13,6 +13,7 @@ const TAB_BAR_FLOATING_MARGIN = 12;
const TAB_BAR_MARGIN = 20; const TAB_BAR_MARGIN = 20;
export default function TabsLayout() { export default function TabsLayout() {
const colors = useAppColors();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const pathname = usePathname(); const pathname = usePathname();
@@ -50,7 +51,7 @@ export default function TabsLayout() {
}; };
} }
return visibleStyle; return visibleStyle;
}, [hideTabBar, insets.bottom, isHomeStackRoute, scrollHideTabBar]); }, [hideTabBar, insets.bottom, isHomeStackRoute, scrollHideTabBar, colors]);
return ( return (
<Tabs <Tabs

View File

@@ -1,19 +1,24 @@
import { useMemo } from 'react';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { AppBackButton } from '../../../../src/components/common'; import { AppBackButton } from '../../../../src/components/common';
import { colors } from '../../../../src/theme'; import { useAppColors } from '../../../../src/theme';
const headerOptions = {
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerTitleStyle: { fontWeight: '600' as const },
headerShadowVisible: false,
headerBackTitle: '',
};
export default function ProfileStackLayout() { export default function ProfileStackLayout() {
const router = useRouter(); const router = useRouter();
const colors = useAppColors();
const headerOptions = useMemo(
() => ({
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerTitleStyle: { fontWeight: '600' as const },
headerShadowVisible: false,
headerBackTitle: '',
}),
[colors]
);
return ( return (
<Stack <Stack

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { AppState, AppStateStatus, Platform, View, ActivityIndicator, StyleSheet } from 'react-native'; import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -7,15 +7,20 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { PaperProvider } from 'react-native-paper'; import { PaperProvider } from 'react-native-paper';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import * as SystemUI from 'expo-system-ui';
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences'; import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
import { paperTheme } from '../src/theme'; import {
ThemeBootstrap,
useAppColors,
usePaperThemeFromStore,
useResolvedColorScheme,
} from '../src/theme';
import { AppBackButton } from '../src/components/common'; import { AppBackButton } from '../src/components/common';
import AppPromptBar from '../src/components/common/AppPromptBar'; import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost'; import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride'; import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores'; import { useAuthStore } from '../src/stores';
import { colors } from '../src/theme';
registerNotificationPresentationHandler(); registerNotificationPresentationHandler();
@@ -47,9 +52,24 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
document.head.appendChild(style); document.head.appendChild(style);
} }
function SystemChrome() {
const colors = useAppColors();
useEffect(() => {
void SystemUI.setBackgroundColorAsync(colors.background.default);
if (Platform.OS === 'web' && typeof document !== 'undefined') {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', colors.background.default);
}
}
}, [colors.background.default]);
return null;
}
function SessionGate({ children }: { children: React.ReactNode }) { function SessionGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser); const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const colors = useAppColors();
useEffect(() => { useEffect(() => {
fetchCurrentUser().finally(() => setReady(true)); fetchCurrentUser().finally(() => setReady(true));
@@ -57,7 +77,14 @@ function SessionGate({ children }: { children: React.ReactNode }) {
if (!ready) { if (!ready) {
return ( return (
<View style={gateStyles.loading}> <View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
</View> </View>
); );
@@ -110,59 +137,69 @@ function NotificationBootstrap() {
return null; return null;
} }
export default function RootLayout() { function ThemedStack() {
const router = useRouter(); const router = useRouter();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
return (
<>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome />
<SessionGate>
<NotificationBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="post/[postId]"
options={{
headerShown: true,
title: '',
// 与 PostDetailScreen 的 SafeAreaViewbackground.default同色避免出现 header/内容之间的色差线
headerStyle: { backgroundColor: colors.background.default },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="user/[userId]"
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
</Stack>
</SessionGate>
</>
);
}
function ThemedProviders({ children }: { children: React.ReactNode }) {
const theme = usePaperThemeFromStore();
return <PaperProvider theme={theme}>{children}</PaperProvider>;
}
export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
<PaperProvider theme={paperTheme}> <ThemedProviders>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<StatusBar style="light" /> <ThemeBootstrap />
<SessionGate> <ThemedStack />
<NotificationBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="post/[postId]"
options={{
headerShown: true,
title: '',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="user/[userId]"
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
</Stack>
</SessionGate>
<AppPromptBar /> <AppPromptBar />
<AppDialogHost /> <AppDialogHost />
</QueryClientProvider> </QueryClientProvider>
</PaperProvider> </ThemedProviders>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }
const gateStyles = StyleSheet.create({
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
},
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -11,7 +11,7 @@ import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { usePathname, useRouter } from 'expo-router'; import { usePathname, useRouter } from 'expo-router';
import { colors, shadows } from '../theme'; import { useAppColors, shadows, type AppColors } from '../theme';
import { useTotalUnreadCount } from '../stores'; import { useTotalUnreadCount } from '../stores';
import { AppRouteStack } from './AppRouteStack'; import { AppRouteStack } from './AppRouteStack';
@@ -36,7 +36,114 @@ function pathToTab(pathname: string): TabName {
return 'HomeTab'; return 'HomeTab';
} }
function createDesktopShellStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.background.default,
},
sidebar: {
backgroundColor: colors.background.paper,
borderRightWidth: 1,
borderRightColor: colors.divider,
flexDirection: 'column',
...shadows.md,
},
sidebarHeader: {
paddingHorizontal: 16,
paddingVertical: 20,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
logoText: {
fontSize: 18,
fontWeight: '700',
color: colors.primary.main,
marginLeft: 8,
},
sidebarContent: {
flex: 1,
paddingTop: 8,
},
sidebarItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
marginHorizontal: 8,
marginVertical: 4,
borderRadius: 12,
},
sidebarItemActive: {
backgroundColor: `${colors.primary.main}15`,
},
sidebarItemCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
sidebarIconContainer: {
position: 'relative',
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
sidebarLabel: {
fontSize: 15,
fontWeight: '500',
color: colors.text.secondary,
marginLeft: 12,
flex: 1,
},
sidebarLabelActive: {
color: colors.primary.main,
fontWeight: '600',
},
collapseButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: colors.divider,
},
collapseButtonCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
badge: {
position: 'absolute',
top: 2,
right: 2,
backgroundColor: colors.error.main,
borderRadius: 10,
minWidth: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
},
badgeText: {
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '600',
},
mainContent: {
flex: 1,
backgroundColor: colors.background.default,
},
});
}
export function AppDesktopShell() { export function AppDesktopShell() {
const colors = useAppColors();
const styles = useMemo(() => createDesktopShellStyles(colors), [colors]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -134,106 +241,3 @@ export function AppDesktopShell() {
</View> </View>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.background.default,
},
sidebar: {
backgroundColor: colors.background.paper,
borderRightWidth: 1,
borderRightColor: colors.divider,
flexDirection: 'column',
...shadows.md,
},
sidebarHeader: {
paddingHorizontal: 16,
paddingVertical: 20,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
logoText: {
fontSize: 18,
fontWeight: '700',
color: colors.primary.main,
marginLeft: 8,
},
sidebarContent: {
flex: 1,
paddingTop: 8,
},
sidebarItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
marginHorizontal: 8,
marginVertical: 4,
borderRadius: 12,
},
sidebarItemActive: {
backgroundColor: `${colors.primary.main}15`,
},
sidebarItemCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
sidebarIconContainer: {
position: 'relative',
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
sidebarLabel: {
fontSize: 15,
fontWeight: '500',
color: colors.text.secondary,
marginLeft: 12,
flex: 1,
},
sidebarLabelActive: {
color: colors.primary.main,
fontWeight: '600',
},
collapseButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: colors.divider,
},
collapseButtonCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
badge: {
position: 'absolute',
top: 2,
right: 2,
backgroundColor: colors.error.main,
borderRadius: 10,
minWidth: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
},
badgeText: {
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '600',
},
mainContent: {
flex: 1,
backgroundColor: colors.background.default,
},
});

View File

@@ -3,12 +3,12 @@
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示 * 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale'; 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 { Comment, CommentImage } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
@@ -31,6 +31,174 @@ interface CommentItemProps {
currentUserId?: string; // 当前用户ID用于判断子评论作者 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> = ({ const CommentItem: React.FC<CommentItemProps> = ({
comment, comment,
onUserPress, onUserPress,
@@ -47,6 +215,8 @@ const CommentItem: React.FC<CommentItemProps> = ({
onImagePress, onImagePress,
currentUserId, currentUserId,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// 格式化时间 // 格式化时间
const formatTime = (dateString: string): string => { 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; export default CommentItem;

View File

@@ -19,7 +19,7 @@ import { PostCardProps, PostCardAction } from './types';
import { ImageGridItem, SmartImage } from '../../common'; import { ImageGridItem, SmartImage } from '../../common';
import Text from '../../common/Text'; import Text from '../../common/Text';
import Avatar from '../../common/Avatar'; 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 { getPreviewImageUrl } from '../../../utils/imageHelper';
import PostImages from './components/PostImages'; import PostImages from './components/PostImages';
import { useResponsive } from '../../../hooks/useResponsive'; import { useResponsive } from '../../../hooks/useResponsive';
@@ -113,6 +113,270 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
return true; 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 主组件 * PostCard 主组件
* 仅支持新 API * 仅支持新 API
@@ -129,6 +393,8 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
style, style,
} = normalizedProps; } = normalizedProps;
const colors = useAppColors();
const styles = useMemo(() => createPostCardStyles(colors), [colors]);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@@ -444,266 +710,4 @@ PostCardInner.displayName = 'PostCard';
const PostCard = memo(PostCardInner, arePostCardPropsEqual); 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; export default PostCard;

View File

@@ -5,7 +5,7 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet } from 'react-native';
import { colors, spacing, borderRadius } from '../../../../theme'; import { spacing, borderRadius } from '../../../../theme';
import { useResponsive } from '../../../../hooks/useResponsive'; import { useResponsive } from '../../../../hooks/useResponsive';
import { ImageGrid, ImageGridItem } from '../../../common'; import { ImageGrid, ImageGridItem } from '../../../common';
import { PostImagesProps } from '../types'; import { PostImagesProps } from '../types';

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -12,7 +12,7 @@ import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as hrefs from '../../navigation/hrefs'; 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'); const { width, height } = Dimensions.get('window');
@@ -21,14 +21,130 @@ interface QRCodeScannerProps {
onClose: () => void; 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 }) => { export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const router = useRouter(); const router = useRouter();
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false); const [scanned, setScanned] = useState(false);
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
// 重置扫描状态,确保每次打开都能重新扫描
setScanned(false); setScanned(false);
if (!permission?.granted) { if (!permission?.granted) {
requestPermission(); requestPermission();
@@ -39,16 +155,11 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => { const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
if (scanned) return; if (scanned) return;
setScanned(true); setScanned(true);
// 先关闭扫描界面,类似微信的做法
onClose(); onClose();
// 解析二维码内容
// 格式: carrotbbs://qrcode/login?session_id=xxx
if (data.startsWith('carrotbbs://qrcode/login')) { if (data.startsWith('carrotbbs://qrcode/login')) {
const sessionId = extractSessionId(data); const sessionId = extractSessionId(data);
if (sessionId) { if (sessionId) {
// 跳转到确认页面
router.push(hrefs.hrefQrLoginConfirm(sessionId)); router.push(hrefs.hrefQrLoginConfirm(sessionId));
} else { } else {
Alert.alert('无效的二维码', '无法识别该二维码'); Alert.alert('无效的二维码', '无法识别该二维码');
@@ -79,7 +190,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
<View style={styles.permissionContainer}> <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> <Text style={styles.permissionText}></Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}> <TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}></Text> <Text style={styles.permissionButtonText}></Text>
@@ -132,117 +243,4 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
); );
}; };
const styles = StyleSheet.create({ export default QRCodeScanner;
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;

View File

@@ -3,10 +3,10 @@
* 用于搜索内容 * 用于搜索内容
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
interface SearchBarProps { interface SearchBarProps {
value: string; value: string;
@@ -18,6 +18,63 @@ interface SearchBarProps {
autoFocus?: boolean; 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> = ({ const SearchBar: React.FC<SearchBarProps> = ({
value, value,
onChangeText, onChangeText,
@@ -27,6 +84,8 @@ const SearchBar: React.FC<SearchBarProps> = ({
onBlur, onBlur,
autoFocus = false, autoFocus = false,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => { const handleFocus = () => {
@@ -59,7 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus={autoFocus} autoFocus={autoFocus}
// 确保光标可见
cursorColor={colors.primary.main} cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`} selectionColor={`${colors.primary.main}40`}
/> />
@@ -69,73 +127,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
style={styles.clearButton} style={styles.clearButton}
activeOpacity={0.7} activeOpacity={0.7}
> >
<MaterialCommunityIcons <MaterialCommunityIcons name="close" size={14} color={colors.text.secondary} />
name="close"
size={14}
color={colors.text.secondary}
/>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </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; export default SearchBar;

View File

@@ -4,12 +4,12 @@
* 采用卡片式设计符合胡萝卜BBS整体风格 * 采用卡片式设计符合胡萝卜BBS整体风格
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, Animated } from 'react-native'; import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale'; 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 { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text'; import Text from '../common/Text';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
@@ -23,9 +23,9 @@ interface SystemMessageItemProps {
index?: number; // 用于交错动画 index?: number; // 用于交错动画
} }
// 系统消息类型到图标和颜色的映射
const getSystemMessageIcon = ( const getSystemMessageIcon = (
systemType: SystemMessageType systemType: SystemMessageType,
colors: AppColors
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => { ): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => {
switch (systemType) { switch (systemType) {
case 'like_post': 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 getSystemMessageTitle = (message: SystemMessageResponse): string => {
const { system_type, extra_data } = message; const { system_type, extra_data } = message;
@@ -177,6 +326,9 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
requestActionLoading = false, requestActionLoading = false,
index = 0, index = 0,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
// 格式化时间 // 格式化时间
const formatTime = (dateString: string): string => { const formatTime = (dateString: string): string => {
try { 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 title = getSystemMessageTitle(message);
const { extra_data } = message; const { extra_data } = message;
const operatorName = extra_data?.actor_name || extra_data?.operator_name; const operatorName = extra_data?.actor_name || extra_data?.operator_name;
@@ -212,17 +364,17 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
const getStatusBadge = () => { const getStatusBadge = () => {
if (requestStatus === 'accepted') { if (requestStatus === 'accepted') {
return ( return (
<View style={[styles.statusBadge, { backgroundColor: '#E8F5E9' }]}> <View style={[styles.statusBadge, { backgroundColor: `${colors.success.main}22` }]}>
<MaterialCommunityIcons name="check" size={10} color="#4CAF50" /> <MaterialCommunityIcons name="check" size={10} color={colors.success.main} />
<Text variant="caption" color="#4CAF50" style={styles.statusText}></Text> <Text variant="caption" color={colors.success.main} style={styles.statusText}></Text>
</View> </View>
); );
} }
if (requestStatus === 'rejected') { if (requestStatus === 'rejected') {
return ( return (
<View style={[styles.statusBadge, { backgroundColor: '#FFEBEE' }]}> <View style={[styles.statusBadge, { backgroundColor: `${colors.error.main}22` }]}>
<MaterialCommunityIcons name="close" size={10} color="#F44336" /> <MaterialCommunityIcons name="close" size={10} color={colors.error.main} />
<Text variant="caption" color="#F44336" style={styles.statusText}></Text> <Text variant="caption" color={colors.error.main} style={styles.statusText}></Text>
</View> </View>
); );
} }
@@ -328,16 +480,16 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
onPress={() => onRequestAction?.(false)} onPress={() => onRequestAction?.(false)}
disabled={requestActionLoading} disabled={requestActionLoading}
> >
<MaterialCommunityIcons name="close" size={14} color="#F44336" /> <MaterialCommunityIcons name="close" size={14} color={colors.error.main} />
<Text variant="caption" color="#F44336" style={styles.actionBtnText}></Text> <Text variant="caption" color={colors.error.main} style={styles.actionBtnText}></Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]} style={[styles.actionBtn, styles.approveBtn]}
onPress={() => onRequestAction?.(true)} onPress={() => onRequestAction?.(true)}
disabled={requestActionLoading} disabled={requestActionLoading}
> >
<MaterialCommunityIcons name="check" size={14} color="#4CAF50" /> <MaterialCommunityIcons name="check" size={14} color={colors.success.main} />
<Text variant="caption" color="#4CAF50" style={styles.actionBtnText}></Text> <Text variant="caption" color={colors.success.main} style={styles.actionBtnText}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </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; export default SystemMessageItem;

View File

@@ -4,10 +4,10 @@
* 新增胶囊式、分段式等现代设计风格 * 新增胶囊式、分段式等现代设计风格
*/ */
import React, { ReactNode } from 'react'; import React, { ReactNode, useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, ScrollView, Animated, ViewStyle, StyleProp } from 'react-native'; import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from '../common/Text';
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern'; type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
@@ -20,10 +20,168 @@ interface TabBarProps {
rightContent?: ReactNode; rightContent?: ReactNode;
variant?: TabBarVariant; variant?: TabBarVariant;
icons?: string[]; icons?: string[];
/** 合并到最外层容器,用于按页面微调外边距等 */
style?: StyleProp<ViewStyle>; 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> = ({ const TabBar: React.FC<TabBarProps> = ({
tabs, tabs,
activeIndex, activeIndex,
@@ -34,6 +192,9 @@ const TabBar: React.FC<TabBarProps> = ({
icons, icons,
style, style,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createTabBarStyles(colors), [colors]);
const renderTabs = () => { const renderTabs = () => {
return tabs.map((tab, index) => { return tabs.map((tab, index) => {
const isActive = index === activeIndex; const isActive = index === activeIndex;
@@ -112,11 +273,7 @@ const TabBar: React.FC<TabBarProps> = ({
style={styles.segmentedTabIcon} style={styles.segmentedTabIcon}
/> />
)} )}
<Text <Text variant="body" color={isActive ? colors.primary.main : colors.text.secondary} style={styles.segmentedTabText}>
variant="body"
color={isActive ? colors.primary.main : colors.text.secondary}
style={styles.segmentedTabText}
>
{tab} {tab}
</Text> </Text>
</View> </View>
@@ -124,7 +281,6 @@ const TabBar: React.FC<TabBarProps> = ({
); );
} }
// default variant
return ( return (
<TouchableOpacity <TouchableOpacity
key={index} key={index}
@@ -161,11 +317,7 @@ const TabBar: React.FC<TabBarProps> = ({
if (scrollable) { if (scrollable) {
return ( return (
<View style={[getContainerStyle(), style]}> <View style={[getContainerStyle(), style]}>
<ScrollView <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollableContainer}>
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollableContainer}
>
{renderTabs()} {renderTabs()}
</ScrollView> </ScrollView>
{rightContent && <View style={styles.rightContent}>{rightContent}</View>} {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; export default TabBar;

View File

@@ -6,7 +6,7 @@
* 在宽屏下显示更大的头像和封面 * 在宽屏下显示更大的头像和封面
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
View, View,
Image, Image,
@@ -15,7 +15,7 @@ import {
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; 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 { User } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
import Button from '../common/Button'; import Button from '../common/Button';
@@ -49,7 +49,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
onFollowersPress, onFollowersPress,
onAvatarPress, onAvatarPress,
}) => { }) => {
// 响应式布局 const colors = useAppColors();
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
const { isWideScreen, isDesktop, width } = useResponsive(); 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: { container: {
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
}, },
@@ -533,7 +535,8 @@ const styles = StyleSheet.create({
alignSelf: 'center', alignSelf: 'center',
padding: spacing.sm, padding: spacing.sm,
}, },
}); });
}
// 使用 React.memo 避免不必要的重新渲染 // 使用 React.memo 避免不必要的重新渲染
const MemoizedUserProfileHeader = React.memo(UserProfileHeader); const MemoizedUserProfileHeader = React.memo(UserProfileHeader);

View File

@@ -4,7 +4,7 @@
* 风格与现代整体UI保持一致 * 风格与现代整体UI保持一致
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@@ -12,7 +12,7 @@ import {
Animated, Animated,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { VoteOptionDTO } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
@@ -28,6 +28,146 @@ interface VoteCardProps {
compact?: boolean; compact?: boolean;
} }
function createVoteCardStyles(colors: AppColors) {
return StyleSheet.create({
container: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.divider,
},
containerCompact: {
padding: spacing.sm,
marginVertical: spacing.xs,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
headerIcon: {
width: 24,
height: 24,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.sm,
},
headerTitle: {
fontWeight: '600',
color: colors.text.primary,
},
optionsList: {
gap: spacing.sm,
},
optionContainer: {
position: 'relative',
borderRadius: borderRadius.md,
overflow: 'hidden',
},
progressBar: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
borderRadius: borderRadius.md,
},
optionButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
minHeight: 44,
borderWidth: 1,
borderColor: 'transparent',
},
optionButtonVoted: {
borderColor: colors.primary.main,
backgroundColor: 'transparent',
},
optionIndicator: {
width: 18,
height: 18,
borderRadius: borderRadius.full,
borderWidth: 2,
borderColor: colors.divider,
marginRight: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
},
optionIndicatorVoted: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
optionText: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
lineHeight: 20,
},
optionTextCompact: {
fontSize: fontSizes.sm,
lineHeight: 18,
},
resultContainer: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: spacing.sm,
minWidth: 40,
justifyContent: 'flex-end',
},
percentage: {
color: colors.text.secondary,
fontWeight: '500',
},
percentageVoted: {
color: colors.primary.main,
fontWeight: '700',
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: spacing.md,
paddingTop: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
},
footerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
footerText: {
fontSize: fontSizes.sm,
},
unvoteButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.sm,
backgroundColor: colors.background.default,
},
unvoteText: {
fontSize: fontSizes.sm,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.background.paper + 'CC',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.lg,
},
});
}
const VoteCard: React.FC<VoteCardProps> = ({ const VoteCard: React.FC<VoteCardProps> = ({
options, options,
totalVotes, totalVotes,
@@ -38,7 +178,8 @@ const VoteCard: React.FC<VoteCardProps> = ({
isLoading = false, isLoading = false,
compact = false, compact = false,
}) => { }) => {
// 动画值 const colors = useAppColors();
const styles = useMemo(() => createVoteCardStyles(colors), [colors]);
const progressAnim = React.useRef(new Animated.Value(0)).current; const progressAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => { React.useEffect(() => {
@@ -151,7 +292,7 @@ const VoteCard: React.FC<VoteCardProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]); }, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]);
// 排序后的选项(已投票的排在前面) // 排序后的选项(已投票的排在前面)
const sortedOptions = React.useMemo(() => { 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; export default VoteCard;

View File

@@ -3,7 +3,7 @@
* 用于创建帖子时编辑投票选项 * 用于创建帖子时编辑投票选项
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@@ -11,7 +11,7 @@ import {
TextInput, TextInput,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from '../common/Text';
interface VoteEditorProps { interface VoteEditorProps {
@@ -24,6 +24,86 @@ interface VoteEditorProps {
maxLength?: number; 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> = ({ const VoteEditor: React.FC<VoteEditorProps> = ({
options, options,
onAddOption, onAddOption,
@@ -33,6 +113,8 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
minOptions = 2, minOptions = 2,
maxLength = 50, maxLength = 50,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createVoteEditorStyles(colors), [colors]);
const validOptionsCount = options.filter(opt => opt.trim() !== '').length; const validOptionsCount = options.filter(opt => opt.trim() !== '').length;
const canAddOption = options.length < maxOptions; const canAddOption = options.length < maxOptions;
const canRemoveOption = options.length > minOptions; 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; export default VoteEditor;

View File

@@ -3,14 +3,14 @@
* 用于帖子列表中显示投票预览,类似微博风格 * 用于帖子列表中显示投票预览,类似微博风格
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
StyleSheet, StyleSheet,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from '../common/Text';
interface VotePreviewProps { interface VotePreviewProps {
@@ -19,11 +19,51 @@ interface VotePreviewProps {
onPress?: () => void; 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> = ({ const VotePreview: React.FC<VotePreviewProps> = ({
totalVotes = 0, totalVotes = 0,
optionsCount = 0, optionsCount = 0,
onPress, onPress,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createVotePreviewStyles(colors), [colors]);
// 格式化票数 // 格式化票数
const formatVoteCount = (count: number): string => { const formatVoteCount = (count: number): string => {
if (count >= 10000) { 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; export default VotePreview;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native'; import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { borderRadius, colors, spacing } from '../../theme'; import { borderRadius, spacing, useAppColors } from '../../theme';
type AppBackButtonIcon = 'arrow-left' | 'close'; type AppBackButtonIcon = 'arrow-left' | 'close';
@@ -20,11 +20,15 @@ const AppBackButton: React.FC<AppBackButtonProps> = ({
onPress, onPress,
icon = 'arrow-left', icon = 'arrow-left',
style, style,
iconColor = colors.text.primary, iconColor: iconColorProp,
backgroundColor = colors.background.paper, backgroundColor: backgroundColorProp,
hitSlop = 8, hitSlop = 8,
testID, testID,
}) => { }) => {
const colors = useAppColors();
const iconColor = iconColorProp ?? colors.text.primary;
const backgroundColor = backgroundColorProp ?? colors.background.paper;
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={onPress} onPress={onPress}

View File

@@ -5,10 +5,98 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { bindDialogListener, DialogPayload } from '../../services/dialogService'; 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 AppDialogHost: React.FC = () => {
const [dialog, setDialog] = useState<DialogPayload | null>(null); const [dialog, setDialog] = useState<DialogPayload | null>(null);
const colors = useAppColors();
const styles = useMemo(() => createDialogHostStyles(colors), [colors]);
useEffect(() => { useEffect(() => {
const unbind = bindDialogListener((payload) => { const unbind = bindDialogListener((payload) => {
@@ -19,7 +107,7 @@ const AppDialogHost: React.FC = () => {
const actions = useMemo<AlertButton[]>(() => { const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }]; if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions.slice(0, 3); return dialog.actions;
}, [dialog]); }, [dialog]);
const onClose = () => { 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; 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 { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService'; 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 { interface PromptState extends PromptPayload {
id: number; id: number;
@@ -12,29 +12,96 @@ interface PromptState extends PromptPayload {
const DEFAULT_DURATION = 2200; const DEFAULT_DURATION = 2200;
const styleMap: Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }> = { function createPromptBarStyles(colors: AppColors) {
info: { backgroundColor: '#FFFFFF', icon: 'information-outline' }, return StyleSheet.create({
success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' }, wrapper: {
warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' }, position: 'absolute',
error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' }, left: spacing.md,
}; right: spacing.md,
zIndex: 9999,
const iconColorMap: Record<PromptType, string> = { },
info: colors.primary.main, card: {
success: colors.success.main, borderRadius: borderRadius.xl,
warning: colors.warning.dark, paddingVertical: spacing.md,
error: colors.error.main, paddingHorizontal: spacing.md,
}; borderWidth: 1,
borderColor: `${colors.primary.main}22`,
const accentColorMap: Record<PromptType, string> = { flexDirection: 'row',
info: colors.primary.main, alignItems: 'center',
success: colors.success.main, overflow: 'hidden',
warning: colors.warning.main, ...shadows.lg,
error: colors.error.main, },
}; 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 AppPromptBar: React.FC = () => {
const insets = useSafeAreaInsets(); 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 [prompt, setPrompt] = useState<PromptState | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animation = useRef(new Animated.Value(0)).current; 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; export default AppPromptBar;

View File

@@ -4,7 +4,7 @@
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载 * 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@@ -12,7 +12,7 @@ import {
ViewStyle, ViewStyle,
} from 'react-native'; } from 'react-native';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { colors, borderRadius } from '../../theme'; import { borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from './Text'; import Text from './Text';
type AvatarSource = string | { uri: string } | number | null; type AvatarSource = string | { uri: string } | number | null;
@@ -27,29 +27,50 @@ interface AvatarProps {
style?: ViewStyle; 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> = ({ const Avatar: React.FC<AvatarProps> = ({
source, source,
size = 40, size = 40,
name, name,
onPress, onPress,
showBadge = false, showBadge = false,
badgeColor = colors.success.main, badgeColor,
style, style,
}) => { }) => {
// 获取首字母 const colors = useAppColors();
const styles = useMemo(() => createAvatarStyles(colors), [colors]);
const resolvedBadgeColor = badgeColor ?? colors.success.main;
const getInitial = (): string => { const getInitial = (): string => {
if (!name) return '?'; if (!name) return '?';
const firstChar = name.charAt(0).toUpperCase(); const firstChar = name.charAt(0).toUpperCase();
// 中文字符
if (/[\u4e00-\u9fa5]/.test(firstChar)) { if (/[\u4e00-\u9fa5]/.test(firstChar)) {
return firstChar; return firstChar;
} }
return firstChar; return firstChar;
}; };
// 渲染头像内容
const renderAvatarContent = () => { const renderAvatarContent = () => {
// 如果有图片源
if (source) { if (source) {
const imageSource = const imageSource =
typeof source === 'string' ? { uri: source } : source; typeof source === 'string' ? { uri: source } : source;
@@ -72,7 +93,6 @@ const Avatar: React.FC<AvatarProps> = ({
); );
} }
// 显示首字母
const fontSize = size / 2; const fontSize = size / 2;
return ( return (
<View <View
@@ -100,7 +120,7 @@ const Avatar: React.FC<AvatarProps> = ({
style={[ style={[
styles.badge, styles.badge,
{ {
backgroundColor: badgeColor, backgroundColor: resolvedBadgeColor,
width: size * 0.3, width: size * 0.3,
height: size * 0.3, height: size * 0.3,
borderRadius: (size * 0.3) / 2, borderRadius: (size * 0.3) / 2,
@@ -124,23 +144,4 @@ const Avatar: React.FC<AvatarProps> = ({
return avatarContainer; 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; export default Avatar;

View File

@@ -3,7 +3,7 @@
* 支持多种变体、尺寸、加载状态和图标 * 支持多种变体、尺寸、加载状态和图标
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
@@ -13,7 +13,7 @@ import {
TextStyle, TextStyle,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from './Text';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger'; type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
@@ -27,12 +27,86 @@ interface ButtonProps {
size?: ButtonSize; size?: ButtonSize;
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
icon?: string; // MaterialCommunityIcons name icon?: string;
iconPosition?: IconPosition; iconPosition?: IconPosition;
fullWidth?: boolean; fullWidth?: boolean;
style?: ViewStyle; 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> = ({ const Button: React.FC<ButtonProps> = ({
title, title,
onPress, onPress,
@@ -45,11 +119,12 @@ const Button: React.FC<ButtonProps> = ({
fullWidth = false, fullWidth = false,
style, style,
}) => { }) => {
// 获取按钮样式 const colors = useAppColors();
const styles = useMemo(() => createButtonStyles(colors), [colors]);
const getButtonStyle = (): ViewStyle[] => { const getButtonStyle = (): ViewStyle[] => {
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]]; const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
// 变体样式
switch (variant) { switch (variant) {
case 'primary': case 'primary':
baseStyle.push(styles.primary); baseStyle.push(styles.primary);
@@ -68,12 +143,10 @@ const Button: React.FC<ButtonProps> = ({
break; break;
} }
// 禁用状态
if (disabled || loading) { if (disabled || loading) {
baseStyle.push(styles.disabled); baseStyle.push(styles.disabled);
} }
// 全宽度
if (fullWidth) { if (fullWidth) {
baseStyle.push(styles.fullWidth); baseStyle.push(styles.fullWidth);
} }
@@ -81,7 +154,6 @@ const Button: React.FC<ButtonProps> = ({
return baseStyle; return baseStyle;
}; };
// 获取文本样式
const getTextStyle = (): TextStyle => { const getTextStyle = (): TextStyle => {
const baseStyle: TextStyle = { const baseStyle: TextStyle = {
...styles.textBase, ...styles.textBase,
@@ -109,7 +181,6 @@ const Button: React.FC<ButtonProps> = ({
return baseStyle; return baseStyle;
}; };
// 获取图标大小
const getIconSize = (): number => { const getIconSize = (): number => {
switch (size) { switch (size) {
case 'sm': case 'sm':
@@ -121,7 +192,6 @@ const Button: React.FC<ButtonProps> = ({
} }
}; };
// 获取图标颜色
const getIconColor = (): string => { const getIconColor = (): string => {
if (disabled || loading) { if (disabled || loading) {
return colors.text.disabled; return colors.text.disabled;
@@ -150,9 +220,9 @@ const Button: React.FC<ButtonProps> = ({
{loading ? ( {loading ? (
<ActivityIndicator <ActivityIndicator
size="small" size="small"
color={variant === 'outline' || variant === 'text' color={
? colors.primary.main variant === 'outline' || variant === 'text' ? colors.primary.main : colors.text.inverse
: colors.text.inverse} }
/> />
) : ( ) : (
<View style={styles.content}> <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; 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 { 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'; type ShadowSize = 'sm' | 'md' | 'lg';
@@ -13,10 +13,20 @@ interface CardProps {
children: React.ReactNode; children: React.ReactNode;
onPress?: () => void; onPress?: () => void;
padding?: number; padding?: number;
shadow?: ShadowSize; shadow?: ShadowSize | 'none';
style?: ViewStyle; style?: ViewStyle;
} }
function createCardStyles(colors: AppColors) {
return StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
});
}
const Card: React.FC<CardProps> = ({ const Card: React.FC<CardProps> = ({
children, children,
onPress, onPress,
@@ -24,13 +34,11 @@ const Card: React.FC<CardProps> = ({
shadow = 'none', shadow = 'none',
style, style,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createCardStyles(colors), [colors]);
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined; const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
const cardStyle = [ const cardStyle = [styles.card, { padding }, shadowStyle].filter(Boolean);
styles.card,
{ padding },
shadowStyle,
].filter(Boolean);
if (onPress) { if (onPress) {
return ( return (
@@ -47,12 +55,4 @@ const Card: React.FC<CardProps> = ({
return <View style={[...cardStyle, style]}>{children}</View>; return <View style={[...cardStyle, style]}>{children}</View>;
}; };
const styles = StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
});
export default Card; export default Card;

View File

@@ -5,7 +5,7 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native'; import { View, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing } from '../../theme'; import { spacing, useAppColors } from '../../theme';
interface DividerProps { interface DividerProps {
margin?: number; margin?: number;
@@ -13,16 +13,14 @@ interface DividerProps {
style?: ViewStyle; style?: ViewStyle;
} }
const Divider: React.FC<DividerProps> = ({ const Divider: React.FC<DividerProps> = ({ margin = spacing.lg, color, style }) => {
margin = spacing.lg, const colors = useAppColors();
color = colors.divider, const lineColor = color ?? colors.divider;
style,
}) => {
return ( return (
<View <View
style={[ style={[
styles.divider, styles.divider,
{ marginVertical: margin, backgroundColor: color }, { marginVertical: margin, backgroundColor: lineColor },
style, style,
]} ]}
/> />

View File

@@ -3,10 +3,10 @@
* 显示空数据时的占位界面,采用现代插图风格设计 * 显示空数据时的占位界面,采用现代插图风格设计
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native'; import { View, StyleSheet, ViewStyle } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 Text from './Text';
import Button from './Button'; import Button from './Button';
@@ -20,6 +20,109 @@ interface EmptyStateProps {
variant?: 'default' | 'modern' | 'compact'; 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> = ({ const EmptyState: React.FC<EmptyStateProps> = ({
icon = 'folder-open-outline', icon = 'folder-open-outline',
title, title,
@@ -29,7 +132,9 @@ const EmptyState: React.FC<EmptyStateProps> = ({
style, style,
variant = 'modern', variant = 'modern',
}) => { }) => {
// 现代风格空状态 const colors = useAppColors();
const styles = useMemo(() => createEmptyStateStyles(colors), [colors]);
if (variant === 'modern') { if (variant === 'modern') {
return ( return (
<View style={[styles.modernContainer, style]}> <View style={[styles.modernContainer, style]}>
@@ -45,19 +150,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
<View style={styles.decorativeCircle1} /> <View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} /> <View style={styles.decorativeCircle2} />
</View> </View>
<Text <Text variant="h3" color={colors.text.primary} style={styles.modernTitle}>
variant="h3"
color={colors.text.primary}
style={styles.modernTitle}
>
{title} {title}
</Text> </Text>
{description && ( {description && (
<Text <Text variant="body" color={colors.text.secondary} style={styles.modernDescription}>
variant="body"
color={colors.text.secondary}
style={styles.modernDescription}
>
{description} {description}
</Text> </Text>
)} )}
@@ -74,7 +171,6 @@ const EmptyState: React.FC<EmptyStateProps> = ({
); );
} }
// 紧凑风格
if (variant === 'compact') { if (variant === 'compact') {
return ( return (
<View style={[styles.compactContainer, style]}> <View style={[styles.compactContainer, style]}>
@@ -84,18 +180,13 @@ const EmptyState: React.FC<EmptyStateProps> = ({
color={colors.text.disabled} color={colors.text.disabled}
style={styles.compactIcon} style={styles.compactIcon}
/> />
<Text <Text variant="body" color={colors.text.secondary} style={styles.compactTitle}>
variant="body"
color={colors.text.secondary}
style={styles.compactTitle}
>
{title} {title}
</Text> </Text>
</View> </View>
); );
} }
// 默认风格
return ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<MaterialCommunityIcons <MaterialCommunityIcons
@@ -104,19 +195,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
color={colors.text.disabled} color={colors.text.disabled}
style={styles.icon} style={styles.icon}
/> />
<Text <Text variant="h3" color={colors.text.secondary} style={styles.title}>
variant="h3"
color={colors.text.secondary}
style={styles.title}
>
{title} {title}
</Text> </Text>
{description && ( {description && (
<Text <Text variant="body" color={colors.text.secondary} style={styles.description}>
variant="body"
color={colors.text.secondary}
style={styles.description}
>
{description} {description}
</Text> </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; export default EmptyState;

View File

@@ -31,7 +31,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system'; 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'); 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 { import {
View, View,
StyleSheet, StyleSheet,
@@ -16,7 +16,7 @@ import {
Image, Image,
} from 'react-native'; } from 'react-native';
import { SmartImage } from './SmartImage'; 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'; import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); 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 管理异步加载到的实际尺寸 // 独立成组件,方便用 useState 管理异步加载到的实际尺寸
@@ -123,6 +206,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
usePreview, usePreview,
displayMode, displayMode,
}) => { }) => {
const styles = useImageGridStyles();
const [aspectRatio, setAspectRatio] = useState<number | null>(null); const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const mainUri = image.uri || image.url || ''; const mainUri = image.uri || image.url || '';
const thumbnailUri = const thumbnailUri =
@@ -245,7 +329,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
displayMode = 'list', displayMode = 'list',
usePreview = true, usePreview = true,
}) => { }) => {
// 通过 onLayout 拿到容器实际宽度 const colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
// 过滤有效图片 - 支持 uri 或 url 字段 // 过滤有效图片 - 支持 uri 或 url 字段
@@ -303,7 +388,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
// 渲染横向双图 // 渲染横向双图
const renderHorizontal = () => { const renderHorizontal = () => {
return ( return (
<View style={[styles.horizontalContainer, { gap }]}> <View style={[gridStyles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => { {displayImages.map((image, index) => {
const previewUrl = usePreview && displayMode const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode) ? getPreviewImageUrl(image as any, displayMode)
@@ -314,7 +399,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || index} key={image.id || index}
onPress={() => handleImagePress(index)} onPress={() => handleImagePress(index)}
style={[ style={[
styles.horizontalItem, gridStyles.horizontalItem,
{ {
flex: 1, flex: 1,
aspectRatio: 1, aspectRatio: 1,
@@ -324,7 +409,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: previewUrl }} source={{ uri: previewUrl }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
@@ -338,7 +423,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
// 渲染网格布局 // 渲染网格布局
const renderGrid = () => { const renderGrid = () => {
return ( return (
<View style={[styles.gridContainer, { gap }]}> <View style={[gridStyles.gridContainer, { gap }]}>
{displayImages.map((image, index) => { {displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1; const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay; const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
@@ -352,8 +437,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || index} key={image.id || index}
onPress={() => handleImagePress(index)} onPress={() => handleImagePress(index)}
style={[ style={[
styles.gridItem, gridStyles.gridItem,
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2, gridColumns === 3 ? gridStyles.gridItem3 : gridStyles.gridItem2,
{ {
borderRadius: borderRadiusValue, borderRadius: borderRadiusValue,
}, },
@@ -361,13 +446,13 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: previewUrl }} source={{ uri: previewUrl }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
{showOverlay && ( {showOverlay && (
<View style={styles.moreOverlay}> <View style={gridStyles.moreOverlay}>
<Text style={styles.moreText}>+{remainingCount}</Text> <Text style={gridStyles.moreText}>+{remainingCount}</Text>
</View> </View>
)} )}
</Pressable> </Pressable>
@@ -397,7 +482,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => { const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
return ( return (
<View style={[styles.masonryColumn, { gap }]}> <View style={[gridStyles.masonryColumn, { gap }]}>
{columnImages.map((image, index) => { {columnImages.map((image, index) => {
const actualIndex = columnIndex + index * 2; const actualIndex = columnIndex + index * 2;
const aspectRatio = image.width && image.height const aspectRatio = image.width && image.height
@@ -414,7 +499,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || actualIndex} key={image.id || actualIndex}
onPress={() => handleImagePress(actualIndex)} onPress={() => handleImagePress(actualIndex)}
style={[ style={[
styles.masonryItem, gridStyles.masonryItem,
{ {
width: itemWidth, width: itemWidth,
height: Math.max(height, itemWidth * 0.7), height: Math.max(height, itemWidth * 0.7),
@@ -424,7 +509,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: previewUrl }} source={{ uri: previewUrl }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
@@ -436,7 +521,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
}; };
return ( return (
<View style={[styles.masonryContainer, { gap }]}> <View style={[gridStyles.masonryContainer, { gap }]}>
{renderColumn(leftColumn, 0)} {renderColumn(leftColumn, 0)}
{renderColumn(rightColumn, 1)} {renderColumn(rightColumn, 1)}
</View> </View>
@@ -464,13 +549,15 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
} }
return ( return (
<View <ImageGridStylesContext.Provider value={gridStyles}>
style={[styles.container, style]} <View
testID={testID} style={[gridStyles.container, style]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)} testID={testID}
> onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
{renderContent()} >
</View> {renderContent()}
</View>
</ImageGridStylesContext.Provider>
); );
}; };
@@ -490,6 +577,8 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
borderRadius: borderRadiusValue = borderRadius.sm, borderRadius: borderRadiusValue = borderRadius.sm,
...props ...props
}) => { }) => {
const colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度 const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
const renderCompactGrid = () => { const renderCompactGrid = () => {
@@ -506,7 +595,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
<Pressable <Pressable
onPress={() => props.onImagePress?.(images, 0)} onPress={() => props.onImagePress?.(images, 0)}
style={[ style={[
styles.compactItem, gridStyles.compactItem,
{ {
width: size, width: size,
height: size, height: size,
@@ -516,7 +605,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }} source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
@@ -529,13 +618,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns); const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
return ( return (
<View style={[styles.compactGrid, { gap }]}> <View style={[gridStyles.compactGrid, { gap }]}>
{images.slice(0, 6).map((image, index) => ( {images.slice(0, 6).map((image, index) => (
<Pressable <Pressable
key={image.id || index} key={image.id || index}
onPress={() => props.onImagePress?.(images, index)} onPress={() => props.onImagePress?.(images, index)}
style={[ style={[
styles.compactItem, gridStyles.compactItem,
{ {
width: itemSize, width: itemSize,
height: itemSize, height: itemSize,
@@ -545,13 +634,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }} source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
{index === 5 && images.length > 6 && ( {index === 5 && images.length > 6 && (
<View style={styles.moreOverlay}> <View style={gridStyles.moreOverlay}>
<Text style={styles.moreText}>+{images.length - 6}</Text> <Text style={gridStyles.moreText}>+{images.length - 6}</Text>
</View> </View>
)} )}
</Pressable> </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; export default ImageGrid;

View File

@@ -3,7 +3,7 @@
* 支持标签、错误提示、图标、多行输入等 * 支持标签、错误提示、图标、多行输入等
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
View, View,
TextInput, TextInput,
@@ -13,7 +13,7 @@ import {
TextStyle, TextStyle,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from './Text';
interface InputProps { interface InputProps {
@@ -36,6 +36,46 @@ interface InputProps {
autoCorrect?: boolean; 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> = ({ const Input: React.FC<InputProps> = ({
value, value,
onChangeText, onChangeText,
@@ -55,6 +95,8 @@ const Input: React.FC<InputProps> = ({
keyboardType = 'default', keyboardType = 'default',
autoCorrect = true, autoCorrect = true,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createInputStyles(colors), [colors]);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const getBorderColor = () => { const getBorderColor = () => {
@@ -87,11 +129,7 @@ const Input: React.FC<InputProps> = ({
/> />
)} )}
<TextInput <TextInput
style={[ style={[styles.input, multiline && styles.multilineInput, inputStyle]}
styles.input,
multiline && styles.multilineInput,
inputStyle,
]}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
placeholder={placeholder} placeholder={placeholder}
@@ -105,15 +143,11 @@ const Input: React.FC<InputProps> = ({
autoCapitalize={autoCapitalize} autoCapitalize={autoCapitalize}
keyboardType={keyboardType} keyboardType={keyboardType}
autoCorrect={autoCorrect} autoCorrect={autoCorrect}
// 确保光标可见
cursorColor={colors.primary.main} cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`} selectionColor={`${colors.primary.main}40`}
/> />
{rightIcon && ( {rightIcon && (
<TouchableOpacity <TouchableOpacity onPress={onRightIconPress} disabled={!onRightIconPress}>
onPress={onRightIconPress}
disabled={!onRightIconPress}
>
<MaterialCommunityIcons <MaterialCommunityIcons
name={rightIcon as any} name={rightIcon as any}
size={20} 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; 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 { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
import { colors } from '../../theme'; import { useAppColors, type AppColors } from '../../theme';
type LoadingSize = 'sm' | 'md' | 'lg'; type LoadingSize = 'sm' | 'md' | 'lg';
@@ -16,12 +16,32 @@ interface LoadingProps {
style?: ViewStyle; 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> = ({ const Loading: React.FC<LoadingProps> = ({
size = 'md', size = 'md',
color = colors.primary.main, color,
fullScreen = false, fullScreen = false,
style, style,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createLoadingStyles(colors), [colors]);
const indicatorColor = color ?? colors.primary.main;
const getSize = (): 'small' | 'large' | undefined => { const getSize = (): 'small' | 'large' | undefined => {
switch (size) { switch (size) {
case 'sm': case 'sm':
@@ -36,30 +56,16 @@ const Loading: React.FC<LoadingProps> = ({
if (fullScreen) { if (fullScreen) {
return ( return (
<View style={[styles.fullScreen, style]}> <View style={[styles.fullScreen, style]}>
<ActivityIndicator size={getSize()} color={color} /> <ActivityIndicator size={getSize()} color={indicatorColor} />
</View> </View>
); );
} }
return ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<ActivityIndicator size={getSize()} color={color} /> <ActivityIndicator size={getSize()} color={indicatorColor} />
</View> </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; 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 动图 * 基于 expo-image 封装,原生支持 GIF/WebP 动图
*/ */
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -17,7 +17,36 @@ import {
} from 'react-native'; } from 'react-native';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; export type ImageLoadState = 'loading' | 'success' | 'error';
@@ -91,6 +120,8 @@ export const SmartImage: React.FC<SmartImageProps> = ({
lazyRootMargin = '160px', lazyRootMargin = '160px',
cachePolicy = 'memory-disk', cachePolicy = 'memory-disk',
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createSmartImageStyles(colors), [colors]);
const [loadState, setLoadState] = useState<ImageLoadState>('loading'); const [loadState, setLoadState] = useState<ImageLoadState>('loading');
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web'); const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false); const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
@@ -244,7 +275,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
<View <View
ref={Platform.OS === 'web' ? lazyTargetRef : undefined} ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
collapsable={false} collapsable={false}
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]} style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.default }]}
testID={testID} testID={testID}
/> />
); );
@@ -328,36 +359,15 @@ export const VariantImage: React.FC<ImageVariantProps> = ({
<SmartImage <SmartImage
{...props} {...props}
style={finalStyle} style={finalStyle}
imageStyle={[styles.variantImage, imageStyle]} imageStyle={[variantStyles.variantImage, imageStyle]}
/> />
); );
}; };
const styles = StyleSheet.create({ const variantStyles = StyleSheet.create({
image: {
flex: 1,
width: '100%',
height: '100%',
},
variantImage: { variantImage: {
flex: 1, 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; export default SmartImage;

View File

@@ -5,7 +5,7 @@
import React from 'react'; import React from 'react';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native'; 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'; type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
@@ -60,6 +60,7 @@ const Text: React.FC<CustomTextProps> = ({
style, style,
...props ...props
}) => { }) => {
const colors = useAppColors();
const textStyle = [ const textStyle = [
styles.base, styles.base,
variantStyles[variant], variantStyles[variant],

View File

@@ -2,13 +2,21 @@
* 应用中心:与首页顶栏、个人主页帖子卡片同一套圆角 / 阴影 / 主色体系 * 应用中心:与首页顶栏、个人主页帖子卡片同一套圆角 / 阴影 / 主色体系
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import { View, StyleSheet, ScrollView, TouchableOpacity, StatusBar } from 'react-native'; import { View, StyleSheet, ScrollView, TouchableOpacity, StatusBar } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import {
spacing,
fontSizes,
borderRadius,
shadows,
useAppColors,
useResolvedColorScheme,
type AppColors,
} from '../../theme';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
@@ -31,20 +39,26 @@ const APP_ENTRIES: AppItem[] = [
}, },
]; ];
/** 与个人主页 PostCard 外层 `postWrapper` 一致 */
const postCardShell = {
marginBottom: spacing.md,
backgroundColor: colors.background.paper,
borderRadius: 16,
overflow: 'hidden' as const,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
};
export const AppsScreen: React.FC = () => { export const AppsScreen: React.FC = () => {
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
const styles = useMemo(() => createAppsStyles(colors), [colors]);
/** 与个人主页 PostCard 外层 `postWrapper` 一致 */
const postCardShell = useMemo(
() => ({
marginBottom: spacing.md,
backgroundColor: colors.background.paper,
borderRadius: 16,
overflow: 'hidden' as const,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
}),
[colors]
);
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
@@ -100,7 +114,7 @@ export const AppsScreen: React.FC = () => {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar barStyle="dark-content" backgroundColor="#FAFAFA" /> <StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.default} />
{/* 与 MessageListScreen 顶栏同一结构 / 样式 */} {/* 与 MessageListScreen 顶栏同一结构 / 样式 */}
<View style={[styles.msgHeader, isWideScreen && styles.msgHeaderWide]}> <View style={[styles.msgHeader, isWideScreen && styles.msgHeaderWide]}>
@@ -143,93 +157,96 @@ export const AppsScreen: React.FC = () => {
export default AppsScreen; export default AppsScreen;
const styles = StyleSheet.create({ function createAppsStyles(colors: AppColors) {
container: { const headerBg = colors.background.default;
flex: 1, return StyleSheet.create({
backgroundColor: colors.background.default, container: {
}, flex: 1,
msgHeader: { backgroundColor: colors.background.default,
flexDirection: 'row', },
alignItems: 'center', msgHeader: {
justifyContent: 'space-between', flexDirection: 'row',
paddingHorizontal: spacing.md, alignItems: 'center',
paddingVertical: spacing.md, justifyContent: 'space-between',
backgroundColor: '#FAFAFA', paddingHorizontal: spacing.md,
...shadows.sm, paddingVertical: spacing.md,
}, backgroundColor: headerBg,
msgHeaderWide: { ...shadows.sm,
paddingHorizontal: spacing.lg, },
paddingVertical: spacing.lg, msgHeaderWide: {
}, paddingHorizontal: spacing.lg,
msgHeaderLeft: { paddingVertical: spacing.lg,
width: 44, },
}, msgHeaderLeft: {
msgHeaderCenter: { width: 44,
flexDirection: 'row', },
alignItems: 'center', msgHeaderCenter: {
gap: spacing.xs, flexDirection: 'row',
}, alignItems: 'center',
msgHeaderTitle: { gap: spacing.xs,
fontSize: 19, },
fontWeight: '700', msgHeaderTitle: {
color: '#333', fontSize: 19,
}, fontWeight: '700',
msgHeaderTitleWide: { color: colors.text.primary,
fontSize: 22, },
}, msgHeaderTitleWide: {
msgHeaderRight: { fontSize: 22,
flexDirection: 'row', },
alignItems: 'center', msgHeaderRight: {
}, flexDirection: 'row',
/** 与消息页「+」按钮同占位宽度,标题视觉居中 */ alignItems: 'center',
msgHeaderRightSpacer: { },
width: 36, /** 与消息页「+」按钮同占位宽度,标题视觉居中 */
height: 44, msgHeaderRightSpacer: {
}, width: 36,
pageSubtitle: { height: 44,
marginBottom: spacing.md, },
lineHeight: fontSizes.sm * 1.45, pageSubtitle: {
}, marginBottom: spacing.md,
/** 与消息列表区同色,避免顶栏阴影落在灰底上像一条线 */ lineHeight: fontSizes.sm * 1.45,
scroll: { },
flex: 1, /** 与消息列表区同色,避免顶栏阴影落在灰底上像一条线 */
backgroundColor: '#FAFAFA', scroll: {
}, flex: 1,
scrollContent: { backgroundColor: headerBg,
paddingTop: spacing.md, },
flexGrow: 1, scrollContent: {
backgroundColor: '#FAFAFA', paddingTop: spacing.md,
}, flexGrow: 1,
appCardInner: { backgroundColor: headerBg,
flexDirection: 'row', },
alignItems: 'center', appCardInner: {
paddingVertical: spacing.lg, flexDirection: 'row',
paddingHorizontal: spacing.lg, alignItems: 'center',
}, paddingVertical: spacing.lg,
/** 与首页发帖 FAB 同主色实心圆 */ paddingHorizontal: spacing.lg,
appIconCircle: { },
width: 52, /** 与首页发帖 FAB 同主色实心圆 */
height: 52, appIconCircle: {
borderRadius: borderRadius.full, width: 52,
backgroundColor: colors.primary.main, height: 52,
alignItems: 'center', borderRadius: borderRadius.full,
justifyContent: 'center', backgroundColor: colors.primary.main,
}, alignItems: 'center',
appCardText: { justifyContent: 'center',
flex: 1, },
marginLeft: spacing.md, appCardText: {
marginRight: spacing.sm, flex: 1,
}, marginLeft: spacing.md,
appTitle: { marginRight: spacing.sm,
fontWeight: '700', },
fontSize: fontSizes.md + 1, appTitle: {
}, fontWeight: '700',
appSubtitle: { fontSize: fontSizes.md + 1,
marginTop: 4, },
}, appSubtitle: {
footer: { marginTop: 4,
alignItems: 'center', },
paddingTop: spacing.xl, footer: {
paddingBottom: spacing.md, alignItems: 'center',
}, paddingTop: spacing.xl,
}); paddingBottom: spacing.md,
},
});
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -14,11 +14,117 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { authService, resolveAuthApiError } from '../../services/authService'; import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService'; import { showPrompt } from '../../services/promptService';
function createForgotPasswordStyles(colors: AppColors) {
return StyleSheet.create({
container: { flex: 1 },
gradient: { flex: 1 },
keyboardView: { flex: 1 },
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'],
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing.xl,
...shadows.md,
},
title: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: colors.text.primary,
textAlign: 'center',
marginBottom: spacing.xs,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
textAlign: 'center',
marginBottom: spacing.lg,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
borderWidth: 1.5,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 52,
marginBottom: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
},
codeInput: {
flex: 1,
marginBottom: 0,
},
sendCodeButton: {
height: 52,
minWidth: 110,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.md,
},
sendCodeButtonDisabled: {
opacity: 0.6,
},
sendCodeButtonText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
submitButton: {
height: 50,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.sm,
},
submitButtonDisabled: {
opacity: 0.6,
},
submitButtonText: {
color: '#fff',
fontSize: fontSizes.lg,
fontWeight: '700',
},
backButton: {
alignItems: 'center',
marginTop: spacing.md,
},
backButtonText: {
color: colors.primary.main,
fontSize: fontSizes.md,
fontWeight: '500',
},
});
}
export const ForgotPasswordScreen: React.FC = () => { export const ForgotPasswordScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState(''); const [verificationCode, setVerificationCode] = useState('');
@@ -199,106 +305,4 @@ export const ForgotPasswordScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({
container: { flex: 1 },
gradient: { flex: 1 },
keyboardView: { flex: 1 },
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'],
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing.xl,
...shadows.md,
},
title: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: colors.text.primary,
textAlign: 'center',
marginBottom: spacing.xs,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
textAlign: 'center',
marginBottom: spacing.lg,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
borderWidth: 1.5,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 52,
marginBottom: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
},
codeInput: {
flex: 1,
marginBottom: 0,
},
sendCodeButton: {
height: 52,
minWidth: 110,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.md,
},
sendCodeButtonDisabled: {
opacity: 0.6,
},
sendCodeButtonText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
submitButton: {
height: 50,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.sm,
},
submitButtonDisabled: {
opacity: 0.6,
},
submitButtonText: {
color: '#fff',
fontSize: fontSizes.lg,
fontWeight: '700',
},
backButton: {
alignItems: 'center',
marginTop: spacing.md,
},
backButtonText: {
color: colors.primary.main,
fontSize: fontSizes.md,
fontWeight: '500',
},
});
export default ForgotPasswordScreen; export default ForgotPasswordScreen;

View File

@@ -7,7 +7,7 @@
* - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰 * - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@@ -25,7 +25,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useResponsiveValue } from '../../hooks'; import { useResponsive, useResponsiveValue } from '../../hooks';
@@ -35,6 +35,8 @@ import { showPrompt } from '../../services/promptService';
const SPLIT_BREAKPOINT = 768; const SPLIT_BREAKPOINT = 768;
export const LoginScreen: React.FC = () => { export const LoginScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createLoginStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const login = useAuthStore((state) => state.login); const login = useAuthStore((state) => state.login);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -453,7 +455,8 @@ export const LoginScreen: React.FC = () => {
return isSplitLayout ? renderSplitLayout() : renderSingleLayout(); return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
}; };
const styles = StyleSheet.create({ function createLoginStyles(colors: AppColors) {
return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
@@ -762,6 +765,7 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
// 移除这里的阴影,避免重复 // 移除这里的阴影,避免重复
}, },
}); });
}
export default LoginScreen; export default LoginScreen;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -12,11 +12,148 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { qrcodeApi } from '../../services/authService'; import { qrcodeApi } from '../../services/authService';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { AppBackButton } from '../../components/common'; import { AppBackButton } from '../../components/common';
function createQrCodeConfirmStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
closeButton: {
padding: spacing.sm,
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
},
placeholder: {
width: 40,
},
content: {
flex: 1,
padding: spacing.lg,
justifyContent: 'center',
},
infoCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: 'center',
marginBottom: spacing.xl,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
infoText: {
fontSize: fontSizes.md,
color: colors.text.secondary,
marginBottom: spacing.lg,
},
userInfo: {
alignItems: 'center',
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: spacing.md,
},
avatarPlaceholder: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.background.disabled,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
},
buttonContainer: {
gap: spacing.md,
},
button: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.md,
alignItems: 'center',
},
confirmButton: {
backgroundColor: colors.primary.main,
},
confirmButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
cancelButton: {
backgroundColor: colors.background.paper,
borderWidth: 1,
borderColor: colors.divider,
},
cancelButtonText: {
color: colors.text.secondary,
fontSize: fontSizes.md,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: spacing.md,
fontSize: fontSizes.md,
color: colors.text.secondary,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
errorText: {
marginTop: spacing.md,
fontSize: fontSizes.md,
color: colors.text.secondary,
textAlign: 'center',
},
backButton: {
marginTop: spacing.xl,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
backgroundColor: colors.primary.main,
borderRadius: borderRadius.md,
},
backButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
});
}
export const QRCodeConfirmScreen: React.FC = () => { export const QRCodeConfirmScreen: React.FC = () => {
const router = useRouter(); const router = useRouter();
const colors = useAppColors();
const styles = useMemo(() => createQrCodeConfirmStyles(colors), [colors]);
const { sessionId: sessionIdParam } = useLocalSearchParams<{ sessionId?: string }>(); const { sessionId: sessionIdParam } = useLocalSearchParams<{ sessionId?: string }>();
const sessionId = sessionIdParam || ''; const sessionId = sessionIdParam || '';
@@ -37,9 +174,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.message || '扫码失败'; const errorMsg = err.response?.data?.message || '扫码失败';
setError(errorMsg); setError(errorMsg);
Alert.alert('扫码失败', errorMsg, [ Alert.alert('扫码失败', errorMsg, [{ text: '确定', onPress: () => router.back() }]);
{ text: '确定', onPress: () => router.back() }
]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -49,9 +184,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
await qrcodeApi.confirm(sessionId); await qrcodeApi.confirm(sessionId);
Alert.alert('登录成功', '网页端已登录', [ Alert.alert('登录成功', '网页端已登录', [{ text: '确定', onPress: () => router.back() }]);
{ text: '确定', onPress: () => router.back() }
]);
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.message || '确认登录失败'; const errorMsg = err.response?.data?.message || '确认登录失败';
Alert.alert('登录失败', errorMsg); Alert.alert('登录失败', errorMsg);
@@ -63,7 +196,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
const handleCancel = async () => { const handleCancel = async () => {
try { try {
await qrcodeApi.cancel(sessionId); await qrcodeApi.cancel(sessionId);
} catch (err) { } catch {
// 忽略取消错误 // 忽略取消错误
} }
router.back(); router.back();
@@ -97,7 +230,12 @@ export const QRCodeConfirmScreen: React.FC = () => {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<AppBackButton onPress={handleCancel} icon="close" style={styles.closeButton} iconColor="#666" /> <AppBackButton
onPress={handleCancel}
icon="close"
style={styles.closeButton}
iconColor={colors.text.secondary}
/>
<Text style={styles.headerTitle}></Text> <Text style={styles.headerTitle}></Text>
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
@@ -105,14 +243,14 @@ export const QRCodeConfirmScreen: React.FC = () => {
<View style={styles.content}> <View style={styles.content}>
<View style={styles.infoCard}> <View style={styles.infoCard}>
<Text style={styles.infoText}></Text> <Text style={styles.infoText}></Text>
{userInfo && ( {userInfo && (
<View style={styles.userInfo}> <View style={styles.userInfo}>
{userInfo.avatar ? ( {userInfo.avatar ? (
<Image source={{ uri: userInfo.avatar }} style={styles.avatar} /> <Image source={{ uri: userInfo.avatar }} style={styles.avatar} />
) : ( ) : (
<View style={styles.avatarPlaceholder}> <View style={styles.avatarPlaceholder}>
<MaterialCommunityIcons name="account" size={40} color="#999" /> <MaterialCommunityIcons name="account" size={40} color={colors.text.disabled} />
</View> </View>
)} )}
<Text style={styles.nickname}>{userInfo.nickname}</Text> <Text style={styles.nickname}>{userInfo.nickname}</Text>
@@ -127,7 +265,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={colors.text.inverse} />
) : ( ) : (
<Text style={styles.confirmButtonText}></Text> <Text style={styles.confirmButtonText}></Text>
)} )}
@@ -146,137 +284,4 @@ export const QRCodeConfirmScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ export default QRCodeConfirmScreen;
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
closeButton: {
padding: spacing.sm,
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: '#333',
},
placeholder: {
width: 40,
},
content: {
flex: 1,
padding: spacing.lg,
justifyContent: 'center',
},
infoCard: {
backgroundColor: '#fff',
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: 'center',
marginBottom: spacing.xl,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
infoText: {
fontSize: fontSizes.md,
color: '#666',
marginBottom: spacing.lg,
},
userInfo: {
alignItems: 'center',
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: spacing.md,
},
avatarPlaceholder: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: '#333',
},
buttonContainer: {
gap: spacing.md,
},
button: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.md,
alignItems: 'center',
},
confirmButton: {
backgroundColor: colors.primary.main,
},
confirmButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '600',
},
cancelButton: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
},
cancelButtonText: {
color: '#666',
fontSize: fontSizes.md,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: spacing.md,
fontSize: fontSizes.md,
color: '#666',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
errorText: {
marginTop: spacing.md,
fontSize: fontSizes.md,
color: '#666',
textAlign: 'center',
},
backButton: {
marginTop: spacing.xl,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
backgroundColor: colors.primary.main,
borderRadius: borderRadius.md,
},
backButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '600',
},
});
export default QRCodeConfirmScreen;

View File

@@ -7,7 +7,7 @@
* - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰 * - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@@ -25,7 +25,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { authService, resolveAuthApiError } from '../../services/authService'; import { authService, resolveAuthApiError } from '../../services/authService';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
@@ -36,6 +36,8 @@ import { showPrompt } from '../../services/promptService';
const SPLIT_BREAKPOINT = 768; const SPLIT_BREAKPOINT = 768;
export const RegisterScreen: React.FC = () => { export const RegisterScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createRegisterStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const register = useAuthStore((state) => state.register); const register = useAuthStore((state) => state.register);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -739,7 +741,8 @@ export const RegisterScreen: React.FC = () => {
return isSplitLayout ? renderSplitLayout() : renderSingleLayout(); return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
}; };
const styles = StyleSheet.create({ function createRegisterStyles(colors: AppColors) {
return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
@@ -1076,6 +1079,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.xl, marginBottom: spacing.xl,
textAlign: 'center', textAlign: 'center',
}, },
}); });
}
export default RegisterScreen; export default RegisterScreen;

View File

@@ -7,7 +7,7 @@
* 投票编辑器在宽屏下优化布局 * 投票编辑器在宽屏下优化布局
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { import {
View, View,
ScrollView, ScrollView,
@@ -25,7 +25,14 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import {
spacing,
fontSizes,
borderRadius,
shadows,
useAppColors,
type AppColors,
} from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
import { channelService, postService, showPrompt, voteService } from '../../services'; import { channelService, postService, showPrompt, voteService } from '../../services';
import { ApiError } from '../../services/api'; import { ApiError } from '../../services/api';
@@ -84,6 +91,8 @@ const getPublishErrorMessage = (error: unknown): string => {
}; };
export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => { export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
const colors = useAppColors();
const styles = useMemo(() => createCreatePostStyles(colors), [colors]);
const { onClose } = props; const { onClose } = props;
const router = useRouter(); const router = useRouter();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -805,7 +814,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
); );
}; };
const styles = StyleSheet.create({ function createCreatePostStyles(colors: AppColors) {
return StyleSheet.create({
flex: { flex: {
flex: 1, flex: 1,
}, },
@@ -1050,6 +1060,7 @@ const styles = StyleSheet.create({
loadingText: { loadingText: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
}, },
}); });
}
export default CreatePostScreen; export default CreatePostScreen;

View File

@@ -24,7 +24,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'; import { useRouter, useFocusEffect } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores'; import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore'; import { useCurrentUser } from '../../stores/authStore';
@@ -56,12 +56,125 @@ type ViewMode = 'list' | 'grid';
type PostType = 'follow' | 'hot' | 'latest'; type PostType = 'follow' | 'hot' | 'latest';
type LatestCapsule = { id: string; name: string }; type LatestCapsule = { id: string; name: string };
function createHomeStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
backgroundColor: colors.background.paper,
},
searchWrapper: {
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
homeTabBar: {
marginTop: spacing.xs,
marginBottom: spacing.sm,
},
viewToggleBtn: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
capsuleWrapper: {
paddingBottom: spacing.sm,
backgroundColor: colors.background.paper,
},
capsuleList: {
gap: spacing.xs,
paddingRight: spacing.lg,
},
capsuleItem: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 999,
},
capsuleText: {
fontSize: 13,
fontWeight: '600',
color: colors.text.secondary,
},
capsuleTextActive: {
color: colors.primary.main,
},
listContent: {
flexGrow: 1,
},
listHeader: {
width: '100%',
},
contentContainer: {
flex: 1,
},
listItem: {},
waterfallScroll: {
flex: 1,
},
waterfallContainer: {
width: '100%',
flexGrow: 1,
},
waterfallColumnsRow: {
width: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
},
waterfallColumn: {
flex: 1,
},
waterfallItem: {},
floatingButton: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
...shadows.lg,
},
floatingButtonDesktop: {
right: 40,
bottom: 40,
width: 64,
height: 64,
borderRadius: 32,
},
floatingButtonWide: {
right: 60,
bottom: 60,
width: 72,
height: 72,
borderRadius: 36,
},
loadingMoreFooter: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
});
}
export const HomeScreen: React.FC = () => { export const HomeScreen: React.FC = () => {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { posts: storePosts } = useUserStore(); const { posts: storePosts } = useUserStore();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
const styles = useMemo(() => createHomeStyles(colors), [colors]);
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
// 使用响应式 hook // 使用响应式 hook
const { const {
width, width,
@@ -827,7 +940,7 @@ export const HomeScreen: React.FC = () => {
if (showSearch) { if (showSearch) {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} /> <StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
<SearchScreen <SearchScreen
onBack={() => setShowSearch(false)} onBack={() => setShowSearch(false)}
/> />
@@ -838,8 +951,8 @@ export const HomeScreen: React.FC = () => {
// 正常首页内容 // 正常首页内容
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} /> <StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
{/* 顶部Header */} {/* 顶部Header */}
<View style={styles.header}> <View style={styles.header}>
{/* 搜索栏 */} {/* 搜索栏 */}
@@ -942,115 +1055,3 @@ export const HomeScreen: React.FC = () => {
</SafeAreaView> </SafeAreaView>
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
backgroundColor: colors.background.paper,
},
searchWrapper: {
paddingTop: spacing.lg,
paddingBottom: spacing.xs,
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
homeTabBar: {
marginTop: 0,
marginBottom: spacing.sm,
},
viewToggleBtn: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
capsuleWrapper: {
paddingBottom: spacing.sm,
backgroundColor: colors.background.paper,
},
capsuleList: {
gap: spacing.xs,
paddingRight: spacing.lg,
},
capsuleItem: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 999,
},
capsuleText: {
fontSize: 13,
fontWeight: '600',
color: colors.text.secondary,
},
capsuleTextActive: {
color: colors.primary.main,
},
listContent: {
flexGrow: 1,
},
listHeader: {
width: '100%',
},
contentContainer: {
flex: 1,
},
listItem: {
// 动态设置 marginBottom
},
waterfallScroll: {
flex: 1,
},
waterfallContainer: {
width: '100%',
flexGrow: 1,
},
waterfallColumnsRow: {
width: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
},
waterfallColumn: {
flex: 1,
},
waterfallItem: {
// 动态设置 marginBottom
},
floatingButton: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
...shadows.lg,
},
floatingButtonDesktop: {
right: 40,
bottom: 40,
width: 64,
height: 64,
borderRadius: 32,
},
floatingButtonWide: {
right: 60,
bottom: 60,
width: 72,
height: 72,
borderRadius: 36,
},
loadingMoreFooter: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -26,7 +26,13 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router'; import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import {
spacing,
fontSizes,
borderRadius,
useAppColors,
type AppColors,
} from '../../theme';
import { Post, Comment, VoteResultDTO } from '../../types'; import { Post, Comment, VoteResultDTO } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore'; import { useCurrentUser } from '../../stores/authStore';
@@ -39,6 +45,8 @@ import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../h
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
export const PostDetailScreen: React.FC = () => { export const PostDetailScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createPostDetailStyles(colors), [colors]);
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -290,6 +298,9 @@ export const PostDetailScreen: React.FC = () => {
}; };
navigation.setOptions({ navigation.setOptions({
// 与页面容器 background.default 一致,并去掉原生 header 底部分隔阴影,避免「一条线」
headerStyle: { backgroundColor: colors.background.default },
headerShadowVisible: false,
headerBackVisible: false, headerBackVisible: false,
headerTitleAlign: 'left', headerTitleAlign: 'left',
headerLeft: () => ( headerLeft: () => (
@@ -324,7 +335,7 @@ export const PostDetailScreen: React.FC = () => {
</View> </View>
), ),
}); });
}, [post?.author, isFollowing, isFollowingMe, isFollowLoading, navigation]); }, [post?.author, isFollowing, isFollowingMe, isFollowLoading, navigation, colors.background.default, styles]);
// 监听键盘事件 // 监听键盘事件
useEffect(() => { useEffect(() => {
@@ -1702,7 +1713,8 @@ export const PostDetailScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createPostDetailStyles(colors: AppColors) {
return StyleSheet.create({
flex: { flex: {
flex: 1, flex: 1,
}, },
@@ -2124,4 +2136,5 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
paddingVertical: spacing.md, paddingVertical: spacing.md,
}, },
}); });
}

View File

@@ -4,7 +4,7 @@
* 支持响应式布局,宽屏下显示更大的搜索结果区域 * 支持响应式布局,宽屏下显示更大的搜索结果区域
*/ */
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { import {
View, View,
FlatList, FlatList,
@@ -16,7 +16,7 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Post, User } from '../../types'; import { Post, User } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { postService, authService } from '../../services'; import { postService, authService } from '../../services';
@@ -37,6 +37,8 @@ interface SearchScreenProps {
} }
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => { export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchScreenStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore(); const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
@@ -544,115 +546,117 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
); );
}; };
const styles = StyleSheet.create({ function createSearchScreenStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
searchHeader: { },
flexDirection: 'row', searchHeader: {
alignItems: 'center', flexDirection: 'row',
backgroundColor: colors.background.paper, alignItems: 'center',
borderBottomWidth: 1, backgroundColor: colors.background.paper,
borderBottomColor: `${colors.divider}70`, borderBottomWidth: 1,
}, borderBottomColor: `${colors.divider}70`,
searchShell: { },
flex: 1, searchShell: {
borderRadius: borderRadius.xl, flex: 1,
backgroundColor: `${colors.primary.main}08`, borderRadius: borderRadius.xl,
paddingHorizontal: spacing.xs, backgroundColor: `${colors.primary.main}08`,
paddingVertical: spacing.xs, paddingHorizontal: spacing.xs,
// 移除阴影效果 paddingVertical: spacing.xs,
shadowColor: 'transparent', // 移除阴影效果
shadowOffset: { width: 0, height: 0 }, shadowColor: 'transparent',
shadowOpacity: 0, shadowOffset: { width: 0, height: 0 },
shadowRadius: 0, shadowOpacity: 0,
elevation: 0, shadowRadius: 0,
}, elevation: 0,
cancelButton: { },
backgroundColor: `${colors.primary.main}14`, cancelButton: {
borderRadius: borderRadius.full, backgroundColor: `${colors.primary.main}14`,
paddingHorizontal: spacing.md, borderRadius: borderRadius.full,
paddingVertical: spacing.xs, paddingHorizontal: spacing.md,
}, paddingVertical: spacing.xs,
cancelText: { },
fontSize: fontSizes.md, cancelText: {
fontWeight: '600', fontSize: fontSizes.md,
}, fontWeight: '600',
tabWrapper: { },
backgroundColor: colors.background.paper, tabWrapper: {
borderBottomWidth: 1, backgroundColor: colors.background.paper,
borderBottomColor: `${colors.divider}50`, borderBottomWidth: 1,
}, borderBottomColor: `${colors.divider}50`,
suggestionsContainer: { },
flex: 1, suggestionsContainer: {
}, flex: 1,
section: { },
marginTop: spacing.md, section: {
}, marginTop: spacing.md,
sectionHeader: { },
flexDirection: 'row', sectionHeader: {
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'center', justifyContent: 'space-between',
marginBottom: spacing.sm, alignItems: 'center',
}, marginBottom: spacing.sm,
sectionTitle: { },
fontWeight: '600', sectionTitle: {
color: colors.text.primary, fontWeight: '600',
}, color: colors.text.primary,
tagContainer: { },
flexDirection: 'row', tagContainer: {
flexWrap: 'wrap', flexDirection: 'row',
}, flexWrap: 'wrap',
tag: { },
flexDirection: 'row', tag: {
alignItems: 'center', flexDirection: 'row',
backgroundColor: colors.background.paper, alignItems: 'center',
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
borderWidth: 1, borderRadius: borderRadius.lg,
borderColor: colors.divider, borderWidth: 1,
}, borderColor: colors.divider,
tagText: { },
marginLeft: spacing.xs, tagText: {
}, marginLeft: spacing.xs,
userCard: { },
backgroundColor: colors.background.paper, userCard: {
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
flexDirection: 'row', borderRadius: borderRadius.lg,
alignItems: 'center', flexDirection: 'row',
}, alignItems: 'center',
userCardInfo: { },
flex: 1, userCardInfo: {
marginLeft: spacing.md, flex: 1,
}, marginLeft: spacing.md,
userCardName: { },
fontWeight: '600', userCardName: {
color: colors.text.primary, fontWeight: '600',
}, color: colors.text.primary,
userItem: { },
flexDirection: 'row', userItem: {
alignItems: 'center', flexDirection: 'row',
backgroundColor: colors.background.paper, alignItems: 'center',
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
}, borderRadius: borderRadius.lg,
userInfo: { },
flex: 1, userInfo: {
marginLeft: spacing.md, flex: 1,
}, marginLeft: spacing.md,
userName: { },
fontWeight: '600', userName: {
color: colors.text.primary, fontWeight: '600',
}, color: colors.text.primary,
followingBadge: { },
width: 20, followingBadge: {
height: 20, width: 20,
borderRadius: 10, height: 20,
backgroundColor: `${colors.primary.main}14`, borderRadius: 10,
alignItems: 'center', backgroundColor: `${colors.primary.main}14`,
justifyContent: 'center', alignItems: 'center',
}, justifyContent: 'center',
loadingMore: { },
paddingVertical: spacing.md, loadingMore: {
alignItems: 'center', paddingVertical: spacing.md,
}, alignItems: 'center',
}); },
});
}

View File

@@ -26,18 +26,20 @@ import {
ActivityIndicator, ActivityIndicator,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
TouchableOpacity,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation, useRouter } from 'expo-router'; import { useNavigation, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, ImageGallery, ImageGridItem } from '../../components/common'; import { Text, ImageGallery, ImageGridItem } from '../../components/common';
import { colors } from '../../theme'; import { useAppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { messageManager } from '../../stores'; import { messageManager } from '../../stores';
import { useBreakpointGTE } from '../../hooks/useResponsive'; import { useBreakpointGTE } from '../../hooks/useResponsive';
import { import {
useChatScreen, useChatScreen,
chatScreenStyles as baseStyles, useChatScreenStyles,
EmojiPanel, EmojiPanel,
MorePanel, MorePanel,
MentionPanel, MentionPanel,
@@ -53,10 +55,11 @@ export const ChatScreen: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const colors = useAppColors();
const styles = useChatScreenStyles();
// 响应式布局 // 响应式布局
const isWideScreen = useBreakpointGTE('lg'); const isWideScreen = useBreakpointGTE('lg');
const styles = baseStyles;
// 监听屏幕宽度变化当变为大屏幕时自动跳转到web端首页 // 监听屏幕宽度变化当变为大屏幕时自动跳转到web端首页
useEffect(() => { useEffect(() => {
@@ -79,6 +82,8 @@ export const ChatScreen: React.FC = () => {
beforeHeight: 0, beforeHeight: 0,
}); });
const preloadCooldownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const preloadCooldownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showJumpToLatestRef = useRef(false);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
const containerStyle = useMemo(() => ([ const containerStyle = useMemo(() => ([
styles.container, styles.container,
@@ -200,6 +205,7 @@ export const ChatScreen: React.FC = () => {
loadMoreHistory, loadMoreHistory,
handleMessageListContentSizeChange, handleMessageListContentSizeChange,
handleReachLatestEdge, handleReachLatestEdge,
jumpToLatestMessages,
setBrowsingHistory, setBrowsingHistory,
} = useChatScreen(); } = useChatScreen();
const displayMessages = useMemo(() => [...messages].reverse(), [messages]); const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
@@ -210,6 +216,18 @@ export const ChatScreen: React.FC = () => {
} }
}, [loadingMore, showEdgeLoadingIndicator]); }, [loadingMore, showEdgeLoadingIndicator]);
useEffect(() => {
if (loading) {
showJumpToLatestRef.current = false;
setShowJumpToLatest(false);
}
}, [loading]);
useEffect(() => {
showJumpToLatestRef.current = false;
setShowJumpToLatest(false);
}, [conversationId]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (preloadCooldownTimerRef.current) { if (preloadCooldownTimerRef.current) {
@@ -314,6 +332,14 @@ export const ChatScreen: React.FC = () => {
viewportHeight: layoutMeasurement.height, viewportHeight: layoutMeasurement.height,
}; };
// invertedoffset 越大离最新消息端越远,与 setBrowsingHistory(120) 阈值一致
const shouldShowJump =
contentOffset.y > 120 && !loading && messages.length > 0;
if (showJumpToLatestRef.current !== shouldShowJump) {
showJumpToLatestRef.current = shouldShowJump;
setShowJumpToLatest(shouldShowJump);
}
// 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底 // 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底
if (contentOffset.y > 120) { if (contentOffset.y > 120) {
setBrowsingHistory(true); setBrowsingHistory(true);
@@ -360,7 +386,17 @@ export const ChatScreen: React.FC = () => {
}, 350); }, 350);
}); });
} }
}, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory]); }, [
scrollPositionRef,
hasMoreHistory,
loadingMore,
loading,
messages.length,
loadMoreHistory,
handleReachLatestEdge,
showEdgeLoadingIndicator,
setBrowsingHistory,
]);
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
handleMessageListContentSizeChange(contentWidth, contentHeight); handleMessageListContentSizeChange(contentWidth, contentHeight);
@@ -474,6 +510,18 @@ export const ChatScreen: React.FC = () => {
</View> </View>
</View> </View>
)} )}
{showJumpToLatest && (
<TouchableOpacity
style={styles.jumpToLatestFab}
onPress={jumpToLatestMessages}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="回到最新消息"
>
<MaterialCommunityIcons name="chevron-down" size={20} color={colors.primary.main} />
<Text style={styles.jumpToLatestFabText}></Text>
</TouchableOpacity>
)}
</View> </View>
{/* 底部区域:输入框 + 面板 */} {/* 底部区域:输入框 + 面板 */}

View File

@@ -4,7 +4,7 @@
* 支持响应式布局 * 支持响应式布局
*/ */
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -19,7 +19,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { groupService } from '../../services/groupService'; import { groupService } from '../../services/groupService';
import { uploadService } from '../../services/uploadService'; import { uploadService } from '../../services/uploadService';
import { Avatar, Text, Button, Loading } from '../../components/common'; import { Avatar, Text, Button, Loading } from '../../components/common';
@@ -27,6 +27,8 @@ import { User } from '../../types';
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
const CreateGroupScreen: React.FC = () => { const CreateGroupScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createCreateGroupStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
// 表单状态 // 表单状态
@@ -310,171 +312,173 @@ const CreateGroupScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createCreateGroupStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
scrollView: { },
flex: 1, scrollView: {
}, flex: 1,
scrollContent: { },
padding: spacing.lg, scrollContent: {
paddingBottom: spacing.xl, padding: spacing.lg,
}, paddingBottom: spacing.xl,
// 头部区域样式 },
headerSection: { // 头部区域样式
flexDirection: 'row', headerSection: {
alignItems: 'flex-start', flexDirection: 'row',
marginBottom: spacing.xl, alignItems: 'flex-start',
}, marginBottom: spacing.xl,
avatarContainer: { },
marginRight: spacing.lg, avatarContainer: {
}, marginRight: spacing.lg,
avatarWrapper: { },
position: 'relative', avatarWrapper: {
}, position: 'relative',
avatarImage: { },
width: 80, avatarImage: {
height: 80, width: 80,
borderRadius: 40, height: 80,
}, borderRadius: 40,
avatarPlaceholder: { },
justifyContent: 'center', avatarPlaceholder: {
alignItems: 'center', justifyContent: 'center',
backgroundColor: colors.background.default, alignItems: 'center',
borderRadius: 40, backgroundColor: colors.background.default,
}, borderRadius: 40,
avatarBadge: { },
position: 'absolute', avatarBadge: {
bottom: 0, position: 'absolute',
right: 0, bottom: 0,
backgroundColor: colors.primary.main, right: 0,
width: 28, backgroundColor: colors.primary.main,
height: 28, width: 28,
borderRadius: 14, height: 28,
justifyContent: 'center', borderRadius: 14,
alignItems: 'center', justifyContent: 'center',
borderWidth: 2, alignItems: 'center',
borderColor: colors.background.paper, borderWidth: 2,
}, borderColor: colors.background.paper,
nameInputContainer: { },
flex: 1, nameInputContainer: {
paddingTop: spacing.sm, flex: 1,
}, paddingTop: spacing.sm,
inputLabel: { },
marginBottom: spacing.sm, inputLabel: {
fontWeight: '600', marginBottom: spacing.sm,
}, fontWeight: '600',
nameInput: { },
backgroundColor: colors.background.paper, nameInput: {
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
paddingHorizontal: spacing.md, borderRadius: borderRadius.lg,
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
fontSize: fontSizes.lg, paddingVertical: spacing.md,
color: colors.text.primary, fontSize: fontSizes.lg,
borderWidth: 1, color: colors.text.primary,
borderColor: colors.divider, borderWidth: 1,
}, borderColor: colors.divider,
charCount: { },
textAlign: 'right', charCount: {
marginTop: spacing.xs, textAlign: 'right',
}, marginTop: spacing.xs,
// 区域样式 },
section: { // 区域样式
marginBottom: spacing.xl, section: {
}, marginBottom: spacing.xl,
sectionHeader: { },
flexDirection: 'row', sectionHeader: {
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'center', justifyContent: 'space-between',
marginBottom: spacing.sm, alignItems: 'center',
}, marginBottom: spacing.sm,
sectionTitle: { },
fontWeight: '600', sectionTitle: {
marginBottom: spacing.sm, fontWeight: '600',
}, marginBottom: spacing.sm,
// 文本域样式 },
textAreaContainer: { // 文本域样式
backgroundColor: colors.background.paper, textAreaContainer: {
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
borderWidth: 1, borderRadius: borderRadius.lg,
borderColor: colors.divider, borderWidth: 1,
padding: spacing.md, borderColor: colors.divider,
}, padding: spacing.md,
textArea: { },
minHeight: 100, textArea: {
fontSize: fontSizes.md, minHeight: 100,
color: colors.text.primary, fontSize: fontSizes.md,
lineHeight: 22, color: colors.text.primary,
}, lineHeight: 22,
textAreaCharCount: { },
textAlign: 'right', textAreaCharCount: {
marginTop: spacing.sm, textAlign: 'right',
}, marginTop: spacing.sm,
// 已选成员样式 },
selectedMembersList: { // 已选成员样式
paddingVertical: spacing.sm, selectedMembersList: {
}, paddingVertical: spacing.sm,
selectedMemberItem: { },
alignItems: 'center', selectedMemberItem: {
marginRight: spacing.lg, alignItems: 'center',
width: 64, marginRight: spacing.lg,
}, width: 64,
removeMemberButton: { },
position: 'absolute', removeMemberButton: {
top: -4, position: 'absolute',
right: 4, top: -4,
}, right: 4,
removeIconContainer: { },
backgroundColor: colors.text.secondary, removeIconContainer: {
borderRadius: 10, backgroundColor: colors.text.secondary,
width: 20, borderRadius: 10,
height: 20, width: 20,
justifyContent: 'center', height: 20,
alignItems: 'center', justifyContent: 'center',
borderWidth: 2, alignItems: 'center',
borderColor: colors.background.paper, borderWidth: 2,
}, borderColor: colors.background.paper,
selectedMemberName: { },
marginTop: spacing.xs, selectedMemberName: {
textAlign: 'center', marginTop: spacing.xs,
width: '100%', textAlign: 'center',
}, width: '100%',
// 邀请按钮样式 },
inviteButton: { // 邀请按钮样式
flexDirection: 'row', inviteButton: {
alignItems: 'center', flexDirection: 'row',
backgroundColor: colors.background.paper, alignItems: 'center',
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
padding: spacing.md, borderRadius: borderRadius.lg,
marginBottom: spacing.xl, padding: spacing.md,
...shadows.sm, marginBottom: spacing.xl,
}, ...shadows.sm,
inviteIconContainer: { },
width: 48, inviteIconContainer: {
height: 48, width: 48,
borderRadius: borderRadius.lg, height: 48,
backgroundColor: colors.primary.light + '20', borderRadius: borderRadius.lg,
justifyContent: 'center', backgroundColor: colors.primary.light + '20',
alignItems: 'center', justifyContent: 'center',
marginRight: spacing.md, alignItems: 'center',
}, marginRight: spacing.md,
inviteTextContainer: { },
flex: 1, inviteTextContainer: {
}, flex: 1,
inviteTitle: { },
fontWeight: '600', inviteTitle: {
marginBottom: 2, fontWeight: '600',
}, marginBottom: 2,
// 底部按钮样式 },
footer: { // 底部按钮样式
padding: spacing.lg, footer: {
paddingBottom: spacing.xl, padding: spacing.lg,
backgroundColor: colors.background.paper, paddingBottom: spacing.xl,
borderTopWidth: 1, backgroundColor: colors.background.paper,
borderTopColor: colors.divider, borderTopWidth: 1,
}, borderTopColor: colors.divider,
}); },
});
}
export default CreateGroupScreen; export default CreateGroupScreen;

View File

@@ -25,7 +25,14 @@ import { useFocusEffect } from '@react-navigation/native';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import {
spacing,
fontSizes,
borderRadius,
shadows,
useAppColors,
type AppColors,
} from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { groupService } from '../../services/groupService'; import { groupService } from '../../services/groupService';
import { uploadService } from '../../services/uploadService'; import { uploadService } from '../../services/uploadService';
@@ -56,6 +63,8 @@ const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: s
]; ];
const GroupInfoScreen: React.FC = () => { const GroupInfoScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createGroupInfoStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const { groupId: groupIdParam, conversationId: conversationIdParam } = useLocalSearchParams<{ const { groupId: groupIdParam, conversationId: conversationIdParam } = useLocalSearchParams<{
groupId?: string | string[]; groupId?: string | string[];
@@ -1071,7 +1080,8 @@ const GroupInfoScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createGroupInfoStyles(colors: AppColors) {
return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
@@ -1594,6 +1604,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.primary.main, backgroundColor: colors.primary.main,
borderColor: colors.primary.main, borderColor: colors.primary.main,
}, },
}); });
}
export default GroupInfoScreen; export default GroupInfoScreen;

View File

@@ -3,7 +3,7 @@ import { View, StyleSheet, ActivityIndicator, Alert, ScrollView, TouchableOpacit
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { Avatar, Text } from '../../components/common'; import { Avatar, Text } from '../../components/common';
import { routePayloadCache } from '../../stores/routePayloadCache'; import { routePayloadCache } from '../../stores/routePayloadCache';
import { groupService } from '../../services/groupService'; import { groupService } from '../../services/groupService';
@@ -12,6 +12,8 @@ import { GroupMemberResponse } from '../../types/dto';
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
const GroupInviteDetailScreen: React.FC = () => { const GroupInviteDetailScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createGroupInviteDetailStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const { messageId } = useLocalSearchParams<{ messageId?: string }>(); const { messageId } = useLocalSearchParams<{ messageId?: string }>();
const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined;
@@ -174,84 +176,86 @@ const GroupInviteDetailScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createGroupInviteDetailStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
emptyWrap: { },
flex: 1, emptyWrap: {
padding: spacing.lg, flex: 1,
justifyContent: 'center', padding: spacing.lg,
alignItems: 'center', justifyContent: 'center',
gap: spacing.md, alignItems: 'center',
}, gap: spacing.md,
backBtn: { },
padding: spacing.sm, backBtn: {
}, padding: spacing.sm,
scrollView: { },
flex: 1, scrollView: {
}, flex: 1,
scrollContent: { },
padding: spacing.lg, scrollContent: {
paddingBottom: spacing.xl, padding: spacing.lg,
}, paddingBottom: spacing.xl,
card: { },
backgroundColor: colors.background.paper, card: {
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
padding: spacing.lg, borderRadius: borderRadius.lg,
...shadows.sm, padding: spacing.lg,
}, ...shadows.sm,
cardHeader: { },
flexDirection: 'row', cardHeader: {
alignItems: 'center', flexDirection: 'row',
marginBottom: spacing.md, alignItems: 'center',
}, marginBottom: spacing.md,
cardIconContainer: { },
width: 30, cardIconContainer: {
height: 30, width: 30,
borderRadius: 15, height: 30,
backgroundColor: colors.info.light + '30', borderRadius: 15,
alignItems: 'center', backgroundColor: colors.info.light + '30',
justifyContent: 'center', alignItems: 'center',
marginRight: spacing.sm, justifyContent: 'center',
}, marginRight: spacing.sm,
cardTitle: { },
fontWeight: '600', cardTitle: {
flex: 1, fontWeight: '600',
}, flex: 1,
memberCount: { },
marginRight: spacing.xs, memberCount: {
}, marginRight: spacing.xs,
loadingWrap: { },
paddingVertical: spacing.md, loadingWrap: {
alignItems: 'center', paddingVertical: spacing.md,
}, alignItems: 'center',
memberPreview: { },
flexDirection: 'row', memberPreview: {
alignItems: 'center', flexDirection: 'row',
flexWrap: 'wrap', alignItems: 'center',
}, flexWrap: 'wrap',
memberAvatar: { },
marginLeft: -8, memberAvatar: {
marginBottom: spacing.sm, marginLeft: -8,
}, marginBottom: spacing.sm,
memberAvatarFirst: { },
marginLeft: 0, memberAvatarFirst: {
}, marginLeft: 0,
ownerBadge: { },
position: 'absolute', ownerBadge: {
bottom: -2, position: 'absolute',
right: -2, bottom: -2,
backgroundColor: colors.warning.main, right: -2,
borderRadius: 8, backgroundColor: colors.warning.main,
paddingHorizontal: 4, borderRadius: 8,
paddingVertical: 1, paddingHorizontal: 4,
}, paddingVertical: 1,
ownerBadgeText: { },
fontSize: 10, ownerBadgeText: {
fontWeight: '700', fontSize: 10,
}, fontWeight: '700',
}); },
});
}
export default GroupInviteDetailScreen; export default GroupInviteDetailScreen;

View File

@@ -21,7 +21,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams } from 'expo-router'; import { useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { groupService } from '../../services/groupService'; import { groupService } from '../../services/groupService';
import { import {
@@ -55,6 +55,8 @@ interface MemberGroup {
} }
const GroupMembersScreen: React.FC = () => { const GroupMembersScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createGroupMembersStyles(colors), [colors]);
const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>(); const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>();
const groupId = firstRouteParam(groupIdParam) ?? ''; const groupId = firstRouteParam(groupIdParam) ?? '';
const { currentUser } = useAuthStore(); const { currentUser } = useAuthStore();
@@ -646,122 +648,124 @@ const GroupMembersScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createGroupMembersStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
section: { },
marginBottom: spacing.md, section: {
}, marginBottom: spacing.md,
sectionHeader: { },
flexDirection: 'row', sectionHeader: {
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: spacing.md, alignItems: 'center',
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
backgroundColor: colors.background.default, paddingVertical: spacing.sm,
}, backgroundColor: colors.background.default,
memberItem: { },
flexDirection: 'row', memberItem: {
alignItems: 'center', flexDirection: 'row',
paddingHorizontal: spacing.md, alignItems: 'center',
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
backgroundColor: colors.background.paper, paddingVertical: spacing.md,
borderBottomWidth: 1, backgroundColor: colors.background.paper,
borderBottomColor: colors.divider, borderBottomWidth: 1,
}, borderBottomColor: colors.divider,
memberInfo: { },
flex: 1, memberInfo: {
marginLeft: spacing.md, flex: 1,
}, marginLeft: spacing.md,
memberNameRow: { },
flexDirection: 'row', memberNameRow: {
alignItems: 'center', flexDirection: 'row',
marginBottom: 2, alignItems: 'center',
}, marginBottom: 2,
memberName: { },
fontWeight: '500', memberName: {
}, fontWeight: '500',
roleBadge: { },
paddingHorizontal: spacing.xs, roleBadge: {
paddingVertical: 2, paddingHorizontal: spacing.xs,
borderRadius: borderRadius.sm, paddingVertical: 2,
marginLeft: spacing.sm, borderRadius: borderRadius.sm,
}, marginLeft: spacing.sm,
mutedBadge: { },
flexDirection: 'row', mutedBadge: {
alignItems: 'center', flexDirection: 'row',
marginTop: 2, alignItems: 'center',
}, marginTop: 2,
// 模态框样式 },
modalOverlay: { // 模态框样式
flex: 1, modalOverlay: {
backgroundColor: 'rgba(0, 0, 0, 0.5)', flex: 1,
justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.5)',
}, justifyContent: 'flex-end',
modalContent: { },
backgroundColor: colors.background.paper, modalContent: {
borderTopLeftRadius: borderRadius.lg, backgroundColor: colors.background.paper,
borderTopRightRadius: borderRadius.lg, borderTopLeftRadius: borderRadius.lg,
padding: spacing.lg, borderTopRightRadius: borderRadius.lg,
maxHeight: '80%', padding: spacing.lg,
}, maxHeight: '80%',
modalHeader: { },
alignItems: 'center', modalHeader: {
marginBottom: spacing.md, alignItems: 'center',
}, marginBottom: spacing.md,
modalTitle: { },
fontWeight: '700', modalTitle: {
marginTop: spacing.sm, fontWeight: '700',
marginBottom: spacing.xs, marginTop: spacing.sm,
}, marginBottom: spacing.xs,
actionItem: { },
flexDirection: 'row', actionItem: {
alignItems: 'center', flexDirection: 'row',
paddingVertical: spacing.md, alignItems: 'center',
borderBottomWidth: 1, paddingVertical: spacing.md,
borderBottomColor: colors.divider, borderBottomWidth: 1,
}, borderBottomColor: colors.divider,
actionText: { },
marginLeft: spacing.md, actionText: {
}, marginLeft: spacing.md,
inputLabel: { },
marginBottom: spacing.xs, inputLabel: {
}, marginBottom: spacing.xs,
input: { },
backgroundColor: colors.background.default, input: {
borderRadius: borderRadius.md, backgroundColor: colors.background.default,
paddingHorizontal: spacing.md, borderRadius: borderRadius.md,
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
fontSize: fontSizes.md, paddingVertical: spacing.md,
color: colors.text.primary, fontSize: fontSizes.md,
borderWidth: 1, color: colors.text.primary,
borderColor: colors.divider, borderWidth: 1,
marginBottom: spacing.md, borderColor: colors.divider,
}, marginBottom: spacing.md,
modalButtons: { },
flexDirection: 'row', modalButtons: {
justifyContent: 'space-between', flexDirection: 'row',
marginTop: spacing.md, justifyContent: 'space-between',
}, marginTop: spacing.md,
modalButton: { },
flex: 1, modalButton: {
marginHorizontal: spacing.xs, flex: 1,
}, marginHorizontal: spacing.xs,
// 分页加载样式 },
loadingFooter: { // 分页加载样式
paddingVertical: spacing.md, loadingFooter: {
alignItems: 'center', paddingVertical: spacing.md,
}, alignItems: 'center',
loadMoreBtn: { },
paddingVertical: spacing.md, loadMoreBtn: {
alignItems: 'center', paddingVertical: spacing.md,
}, alignItems: 'center',
noMoreText: { },
textAlign: 'center', noMoreText: {
paddingVertical: spacing.md, textAlign: 'center',
}, paddingVertical: spacing.md,
}); },
});
}
export default GroupMembersScreen; export default GroupMembersScreen;

View File

@@ -4,7 +4,7 @@ import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { Avatar, Text } from '../../components/common'; import { Avatar, Text } from '../../components/common';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { routePayloadCache } from '../../stores/routePayloadCache'; import { routePayloadCache } from '../../stores/routePayloadCache';
@@ -14,6 +14,8 @@ import { userManager } from '../../stores/userManager';
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
const GroupRequestDetailScreen: React.FC = () => { const GroupRequestDetailScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createGroupRequestDetailStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const { messageId } = useLocalSearchParams<{ messageId?: string }>(); const { messageId } = useLocalSearchParams<{ messageId?: string }>();
const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined;
@@ -174,52 +176,54 @@ const GroupRequestDetailScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createGroupRequestDetailStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
emptyWrap: { },
flex: 1, emptyWrap: {
padding: spacing.lg, flex: 1,
justifyContent: 'center', padding: spacing.lg,
alignItems: 'center', justifyContent: 'center',
gap: spacing.md, alignItems: 'center',
}, gap: spacing.md,
backBtn: { },
padding: spacing.sm, backBtn: {
}, padding: spacing.sm,
scrollView: { },
flex: 1, scrollView: {
}, flex: 1,
scrollContent: { },
padding: spacing.lg, scrollContent: {
paddingBottom: spacing.xl, padding: spacing.lg,
}, paddingBottom: spacing.xl,
card: { },
backgroundColor: colors.background.paper, card: {
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
padding: spacing.lg, borderRadius: borderRadius.lg,
marginBottom: spacing.md, padding: spacing.lg,
...shadows.sm, marginBottom: spacing.md,
}, ...shadows.sm,
sectionTitle: { },
marginBottom: spacing.sm, sectionTitle: {
}, marginBottom: spacing.sm,
row: { },
flexDirection: 'row', row: {
alignItems: 'center', flexDirection: 'row',
}, alignItems: 'center',
meta: { },
marginLeft: spacing.md, meta: {
flex: 1, marginLeft: spacing.md,
}, flex: 1,
name: { },
marginBottom: 2, name: {
}, marginBottom: 2,
subDesc: { },
marginTop: spacing.sm, subDesc: {
}, marginTop: spacing.sm,
}); },
});
}
export default GroupRequestDetailScreen; export default GroupRequestDetailScreen;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -13,7 +13,7 @@ import {
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../../theme'; import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
import Avatar from '../../components/common/Avatar'; import Avatar from '../../components/common/Avatar';
import Text from '../../components/common/Text'; import Text from '../../components/common/Text';
import { groupService } from '../../services/groupService'; import { groupService } from '../../services/groupService';
@@ -22,7 +22,160 @@ import { GroupResponse, JoinType } from '../../types/dto';
import { useCursorPagination } from '../../hooks/useCursorPagination'; import { useCursorPagination } from '../../hooks/useCursorPagination';
import { EmptyState } from '../../components/common'; import { EmptyState } from '../../components/common';
function createJoinGroupStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
padding: spacing.lg,
},
heroCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.lg,
},
heroIconWrap: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.primary.light + '26',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
heroTitle: {
marginBottom: spacing.xs,
},
tip: {
lineHeight: 20,
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
flex: 1,
},
label: {
marginBottom: spacing.xs,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: colors.divider,
borderRadius: borderRadius.md,
backgroundColor: colors.background.paper,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
color: colors.text.primary,
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.md,
},
searchBtn: {
width: 46,
height: 46,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
},
searchResultSection: {
marginBottom: spacing.lg,
},
sectionTitle: {
marginBottom: spacing.sm,
fontWeight: '600',
},
listSection: {
flex: 1,
},
groupCard: {
borderWidth: 1,
borderColor: colors.divider,
borderRadius: borderRadius.md,
padding: spacing.md,
backgroundColor: colors.background.default,
marginBottom: spacing.md,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
},
groupMeta: {
marginLeft: spacing.md,
flex: 1,
},
groupName: {
marginBottom: spacing.xs,
fontWeight: '600',
},
groupDesc: {
marginTop: spacing.sm,
lineHeight: 18,
},
groupInfoRow: {
marginTop: spacing.sm,
marginBottom: spacing.xs,
flexDirection: 'row',
justifyContent: 'space-between',
},
groupNoRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
copyBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.light + '22',
},
copyBtnText: {
marginLeft: 4,
},
submitBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
minHeight: 46,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitText: {
marginLeft: spacing.xs,
},
emptyText: {
marginTop: spacing.sm,
textAlign: 'center',
},
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
},
});
}
const JoinGroupScreen: React.FC = () => { const JoinGroupScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createJoinGroupStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
@@ -295,153 +448,4 @@ const JoinGroupScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
padding: spacing.lg,
},
heroCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.lg,
},
heroIconWrap: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.primary.light + '26',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
heroTitle: {
marginBottom: spacing.xs,
},
tip: {
lineHeight: 20,
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
flex: 1,
},
label: {
marginBottom: spacing.xs,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: colors.divider,
borderRadius: borderRadius.md,
backgroundColor: colors.background.paper,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
color: colors.text.primary,
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.md,
},
searchBtn: {
width: 46,
height: 46,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
},
searchResultSection: {
marginBottom: spacing.lg,
},
sectionTitle: {
marginBottom: spacing.sm,
fontWeight: '600',
},
listSection: {
flex: 1,
},
groupCard: {
borderWidth: 1,
borderColor: colors.divider,
borderRadius: borderRadius.md,
padding: spacing.md,
backgroundColor: colors.background.default,
marginBottom: spacing.md,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
},
groupMeta: {
marginLeft: spacing.md,
flex: 1,
},
groupName: {
marginBottom: spacing.xs,
fontWeight: '600',
},
groupDesc: {
marginTop: spacing.sm,
lineHeight: 18,
},
groupInfoRow: {
marginTop: spacing.sm,
marginBottom: spacing.xs,
flexDirection: 'row',
justifyContent: 'space-between',
},
groupNoRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
copyBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.light + '22',
},
copyBtnText: {
marginLeft: 4,
},
submitBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
minHeight: 46,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitText: {
marginLeft: spacing.xs,
},
emptyText: {
marginTop: spacing.sm,
textAlign: 'center',
},
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
},
});
export default JoinGroupScreen; export default JoinGroupScreen;

View File

@@ -30,7 +30,14 @@ import { useRouter } from 'expo-router';
import { useIsFocused } from '@react-navigation/native'; import { useIsFocused } from '@react-navigation/native';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; import {
spacing,
fontSizes,
shadows,
borderRadius,
useAppColors,
type AppColors,
} from '../../theme';
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto'; import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
import { authService } from '../../services'; import { authService } from '../../services';
import { useUserStore, useAuthStore } from '../../stores'; import { useUserStore, useAuthStore } from '../../stores';
@@ -73,6 +80,8 @@ interface SearchResultItem {
* 支持响应式双栏布局 * 支持响应式双栏布局
*/ */
export const MessageListScreen: React.FC = () => { export const MessageListScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createMessageListStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -565,7 +574,7 @@ export const MessageListScreen: React.FC = () => {
))} ))}
</View> </View>
</View> </View>
<MaterialCommunityIcons name="chevron-right" size={20} color="#CCC" /> <MaterialCommunityIcons name="chevron-right" size={20} color={colors.chat.iconSoft} />
</TouchableOpacity> </TouchableOpacity>
); );
} }
@@ -614,7 +623,7 @@ export const MessageListScreen: React.FC = () => {
</View> </View>
)} )}
{!user.is_following && ( {!user.is_following && (
<MaterialCommunityIcons name="chevron-right" size={20} color="#CCC" /> <MaterialCommunityIcons name="chevron-right" size={20} color={colors.chat.iconSoft} />
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
@@ -661,11 +670,11 @@ export const MessageListScreen: React.FC = () => {
const renderSearchMode = () => ( const renderSearchMode = () => (
<View style={styles.searchModeContainer}> <View style={styles.searchModeContainer}>
<View style={styles.searchInputContainer}> <View style={styles.searchInputContainer}>
<AppBackButton onPress={handleCloseSearch} iconColor="#666" /> <AppBackButton onPress={handleCloseSearch} iconColor={colors.text.secondary} />
<TextInput <TextInput
style={styles.searchInput} style={styles.searchInput}
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'} placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
placeholderTextColor="#999" placeholderTextColor={colors.text.hint}
value={searchText} value={searchText}
onChangeText={setSearchText} onChangeText={setSearchText}
autoFocus autoFocus
@@ -676,7 +685,7 @@ export const MessageListScreen: React.FC = () => {
/> />
{searchText.length > 0 && ( {searchText.length > 0 && (
<TouchableOpacity onPress={() => setSearchText('')}> <TouchableOpacity onPress={() => setSearchText('')}>
<MaterialCommunityIcons name="close-circle" size={18} color="#999" /> <MaterialCommunityIcons name="close-circle" size={18} color={colors.text.hint} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@@ -733,7 +742,7 @@ export const MessageListScreen: React.FC = () => {
<View style={styles.headerRightContainer}> <View style={styles.headerRightContainer}>
<TouchableOpacity style={styles.headerRightBtn} onPress={handleOpenActionMenu}> <TouchableOpacity style={styles.headerRightBtn} onPress={handleOpenActionMenu}>
<MaterialCommunityIcons name="plus" size={24} color="#666" /> <MaterialCommunityIcons name="plus" size={24} color={colors.text.secondary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -744,7 +753,7 @@ export const MessageListScreen: React.FC = () => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.searchBox}> <View style={styles.searchBox}>
<MaterialCommunityIcons name="magnify" size={18} color="#999" /> <MaterialCommunityIcons name="magnify" size={18} color={colors.text.hint} />
<Text style={styles.searchPlaceholder}></Text> <Text style={styles.searchPlaceholder}></Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -793,19 +802,19 @@ export const MessageListScreen: React.FC = () => {
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}> <Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
<View style={styles.actionMenu}> <View style={styles.actionMenu}>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('scanQRCode')}> <TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('scanQRCode')}>
<MaterialCommunityIcons name="qrcode-scan" size={21} color="#444" /> <MaterialCommunityIcons name="qrcode-scan" size={21} color={colors.text.primary} />
<Text style={styles.actionMenuText}></Text> <Text style={styles.actionMenuText}></Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}> <TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
<MaterialCommunityIcons name="account-plus-outline" size={21} color="#444" /> <MaterialCommunityIcons name="account-plus-outline" size={21} color={colors.text.primary} />
<Text style={styles.actionMenuText}></Text> <Text style={styles.actionMenuText}></Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('create')}> <TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('create')}>
<MaterialCommunityIcons name="account-multiple-plus" size={21} color="#444" /> <MaterialCommunityIcons name="account-multiple-plus" size={21} color={colors.text.primary} />
<Text style={styles.actionMenuText}></Text> <Text style={styles.actionMenuText}></Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('readAll')} disabled={isMarking}> <TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('readAll')} disabled={isMarking}>
<MaterialCommunityIcons name="message-check-outline" size={21} color="#444" /> <MaterialCommunityIcons name="message-check-outline" size={21} color={colors.text.primary} />
<Text style={styles.actionMenuText}>{isMarking ? '处理中...' : '全部已读'}</Text> <Text style={styles.actionMenuText}>{isMarking ? '处理中...' : '全部已读'}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -838,7 +847,7 @@ export const MessageListScreen: React.FC = () => {
// 默认占位符 // 默认占位符
<View style={styles.chatPlaceholder}> <View style={styles.chatPlaceholder}>
<View style={styles.chatPlaceholderContent}> <View style={styles.chatPlaceholderContent}>
<MaterialCommunityIcons name="message-text-outline" size={64} color="#DDD" /> <MaterialCommunityIcons name="message-text-outline" size={64} color={colors.chat.iconMuted} />
<Text style={styles.chatPlaceholderText}></Text> <Text style={styles.chatPlaceholderText}></Text>
</View> </View>
</View> </View>
@@ -870,288 +879,290 @@ export const MessageListScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createMessageListStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: '#FAFAFA', flex: 1,
}, backgroundColor: colors.background.default,
// 桌面端双栏布局 },
desktopContainer: { // 桌面端双栏布局
flex: 1, desktopContainer: {
flexDirection: 'row', flex: 1,
}, flexDirection: 'row',
sidebar: { },
backgroundColor: '#FAFAFA', sidebar: {
borderRightWidth: 1, backgroundColor: colors.background.default,
borderRightColor: '#E8E8E8', borderRightWidth: 1,
}, borderRightColor: colors.chat.border,
chatArea: { },
flex: 1, chatArea: {
backgroundColor: '#F5F7FA', flex: 1,
}, backgroundColor: colors.chat.surfaceMuted,
chatPlaceholder: { },
flex: 1, chatPlaceholder: {
backgroundColor: '#F5F7FA', flex: 1,
alignItems: 'center', backgroundColor: colors.chat.surfaceMuted,
justifyContent: 'center', alignItems: 'center',
}, justifyContent: 'center',
chatPlaceholderContent: { },
alignItems: 'center', chatPlaceholderContent: {
justifyContent: 'center', alignItems: 'center',
}, justifyContent: 'center',
chatPlaceholderText: { },
marginTop: spacing.md, chatPlaceholderText: {
fontSize: 16, marginTop: spacing.md,
color: '#999', fontSize: 16,
}, color: colors.text.hint,
header: { },
flexDirection: 'row', header: {
alignItems: 'center', flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center',
paddingHorizontal: spacing.md, justifyContent: 'space-between',
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
backgroundColor: '#FAFAFA', paddingVertical: spacing.md,
...shadows.sm, backgroundColor: colors.background.default,
}, ...shadows.sm,
headerWide: { },
paddingHorizontal: spacing.lg, headerWide: {
paddingVertical: spacing.lg, paddingHorizontal: spacing.lg,
}, paddingVertical: spacing.lg,
headerTitleWide: { },
fontSize: 22, headerTitleWide: {
}, fontSize: 22,
searchContainerWide: { },
paddingHorizontal: spacing.lg, searchContainerWide: {
}, paddingHorizontal: spacing.lg,
listContentWide: { },
paddingHorizontal: spacing.lg, listContentWide: {
}, paddingHorizontal: spacing.lg,
headerLeft: { },
width: 44, headerLeft: {
}, width: 44,
headerCenter: { },
flexDirection: 'row', headerCenter: {
alignItems: 'center', flexDirection: 'row',
gap: spacing.xs, alignItems: 'center',
}, gap: spacing.xs,
headerTitle: { },
fontSize: 19, headerTitle: {
fontWeight: '700', fontSize: 19,
color: '#333', fontWeight: '700',
}, color: colors.text.primary,
totalBadge: { },
minWidth: 18, totalBadge: {
height: 18, minWidth: 18,
borderRadius: 9, height: 18,
backgroundColor: '#FF4757', borderRadius: 9,
alignItems: 'center', backgroundColor: colors.error.main,
justifyContent: 'center', alignItems: 'center',
paddingHorizontal: 5, justifyContent: 'center',
}, paddingHorizontal: 5,
totalBadgeText: { },
color: '#FFF', totalBadgeText: {
fontSize: 11, color: colors.primary.contrast,
fontWeight: '600', fontSize: 11,
}, fontWeight: '600',
headerRight: { },
width: 44, headerRight: {
height: 44, width: 44,
alignItems: 'center', height: 44,
justifyContent: 'center', alignItems: 'center',
}, justifyContent: 'center',
headerRightContainer: { },
flexDirection: 'row', headerRightContainer: {
alignItems: 'center', flexDirection: 'row',
}, alignItems: 'center',
headerRightBtn: { },
width: 36, headerRightBtn: {
height: 44, width: 36,
alignItems: 'center', height: 44,
justifyContent: 'center', alignItems: 'center',
}, justifyContent: 'center',
actionMenuBackdrop: { },
flex: 1, actionMenuBackdrop: {
backgroundColor: 'rgba(0,0,0,0.08)', flex: 1,
}, backgroundColor: 'rgba(0,0,0,0.08)',
actionMenu: { },
position: 'absolute', actionMenu: {
top: 88, position: 'absolute',
right: spacing.md, top: 88,
width: 188, right: spacing.md,
backgroundColor: '#FFF', width: 188,
borderRadius: 12, backgroundColor: colors.background.paper,
paddingVertical: spacing.sm, borderRadius: 12,
...shadows.md, paddingVertical: spacing.sm,
}, ...shadows.md,
actionMenuItem: { },
flexDirection: 'row', actionMenuItem: {
alignItems: 'center', flexDirection: 'row',
paddingHorizontal: spacing.md, alignItems: 'center',
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
}, paddingVertical: spacing.md,
actionMenuText: { },
marginLeft: spacing.sm, actionMenuText: {
fontSize: 16, marginLeft: spacing.sm,
color: '#333', fontSize: 16,
}, color: colors.text.primary,
searchContainer: { },
paddingHorizontal: spacing.md, searchContainer: {
paddingBottom: spacing.sm, paddingHorizontal: spacing.md,
backgroundColor: '#FAFAFA', paddingBottom: spacing.sm,
}, backgroundColor: colors.background.default,
searchBox: { },
flexDirection: 'row', searchBox: {
alignItems: 'center', flexDirection: 'row',
backgroundColor: '#F0F0F0', alignItems: 'center',
borderRadius: 10, backgroundColor: colors.chat.surfaceInput,
paddingHorizontal: spacing.sm, borderRadius: 10,
paddingVertical: 10, paddingHorizontal: spacing.sm,
}, paddingVertical: 10,
searchPlaceholder: { },
fontSize: 14, searchPlaceholder: {
color: '#999', fontSize: 14,
marginLeft: spacing.xs, color: colors.text.hint,
}, marginLeft: spacing.xs,
listContent: { },
flexGrow: 1, listContent: {
backgroundColor: '#FAFAFA', flexGrow: 1,
}, backgroundColor: colors.background.default,
loadingContainer: { },
flex: 1, loadingContainer: {
justifyContent: 'center', flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: spacing.xl * 2, alignItems: 'center',
}, paddingVertical: spacing.xl * 2,
loadingMoreContainer: { },
flexDirection: 'row', loadingMoreContainer: {
alignItems: 'center', flexDirection: 'row',
justifyContent: 'center', alignItems: 'center',
paddingVertical: spacing.md, justifyContent: 'center',
}, paddingVertical: spacing.md,
loadingMoreText: { },
marginLeft: spacing.sm, loadingMoreText: {
fontSize: 14, marginLeft: spacing.sm,
color: '#999', fontSize: 14,
}, color: colors.text.hint,
searchModeContainer: { },
flex: 1, searchModeContainer: {
backgroundColor: '#FAFAFA', flex: 1,
}, backgroundColor: colors.background.default,
searchInputContainer: { },
flexDirection: 'row', searchInputContainer: {
alignItems: 'center', flexDirection: 'row',
backgroundColor: '#F0F0F0', alignItems: 'center',
borderRadius: 10, backgroundColor: colors.chat.surfaceInput,
paddingHorizontal: spacing.md, borderRadius: 10,
marginHorizontal: spacing.md, paddingHorizontal: spacing.md,
marginTop: spacing.md, marginHorizontal: spacing.md,
height: 40, marginTop: spacing.md,
}, height: 40,
searchInput: { },
flex: 1, searchInput: {
fontSize: 15, flex: 1,
color: '#333', fontSize: 15,
marginLeft: spacing.md, color: colors.text.primary,
}, marginLeft: spacing.md,
searchTabs: { },
flexDirection: 'row', searchTabs: {
paddingHorizontal: spacing.md, flexDirection: 'row',
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
gap: spacing.sm, paddingVertical: spacing.sm,
}, gap: spacing.sm,
searchTab: { },
flex: 1, searchTab: {
paddingVertical: spacing.sm, flex: 1,
alignItems: 'center', paddingVertical: spacing.sm,
backgroundColor: '#F0F0F0', alignItems: 'center',
borderRadius: 8, backgroundColor: colors.chat.surfaceInput,
}, borderRadius: 8,
searchTabActive: { },
backgroundColor: colors.primary.main, searchTabActive: {
}, backgroundColor: colors.primary.main,
searchTabText: { },
fontSize: 14, searchTabText: {
color: '#666', fontSize: 14,
fontWeight: '500', color: colors.text.secondary,
}, fontWeight: '500',
searchTabTextActive: { },
color: '#FFF', searchTabTextActive: {
}, color: colors.primary.contrast,
searchResultsContent: { },
flexGrow: 1, searchResultsContent: {
paddingHorizontal: spacing.md, flexGrow: 1,
}, paddingHorizontal: spacing.md,
searchResultItem: { },
flexDirection: 'row', searchResultItem: {
alignItems: 'center', flexDirection: 'row',
paddingVertical: spacing.md, alignItems: 'center',
backgroundColor: '#FFF', paddingVertical: spacing.md,
borderRadius: 10, backgroundColor: colors.background.paper,
marginBottom: spacing.sm, borderRadius: 10,
paddingHorizontal: spacing.md, marginBottom: spacing.sm,
}, paddingHorizontal: spacing.md,
searchResultContent: { },
flex: 1, searchResultContent: {
marginLeft: spacing.md, flex: 1,
}, marginLeft: spacing.md,
searchResultHeader: { },
flexDirection: 'row', searchResultHeader: {
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'center', justifyContent: 'space-between',
marginBottom: 2, alignItems: 'center',
}, marginBottom: 2,
searchResultName: { },
fontSize: 15, searchResultName: {
fontWeight: '600', fontSize: 15,
color: '#333', fontWeight: '600',
}, color: colors.text.primary,
searchResultTime: { },
fontSize: 12, searchResultTime: {
color: '#999', fontSize: 12,
}, color: colors.text.hint,
searchResultMessage: { },
fontSize: 13, searchResultMessage: {
color: '#888', fontSize: 13,
}, color: colors.text.secondary,
searchingText: { },
marginTop: spacing.sm, searchingText: {
fontSize: 14, marginTop: spacing.sm,
color: '#999', fontSize: 14,
}, color: colors.text.hint,
followingBadge: { },
width: 24, followingBadge: {
height: 24, width: 24,
borderRadius: 12, height: 24,
backgroundColor: colors.primary.light + '30', borderRadius: 12,
alignItems: 'center', backgroundColor: colors.primary.light + '30',
justifyContent: 'center', alignItems: 'center',
}, justifyContent: 'center',
highlightText: { },
color: colors.primary.main, highlightText: {
fontWeight: '700', color: colors.primary.main,
backgroundColor: colors.primary.light + '30', fontWeight: '700',
}, backgroundColor: colors.primary.light + '30',
matchedMessagesContainer: { },
marginTop: spacing.sm, matchedMessagesContainer: {
}, marginTop: spacing.sm,
matchedMessageItem: { },
flexDirection: 'row', matchedMessageItem: {
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'flex-start', justifyContent: 'space-between',
paddingVertical: spacing.xs, alignItems: 'flex-start',
borderBottomWidth: StyleSheet.hairlineWidth, paddingVertical: spacing.xs,
borderBottomColor: '#F0F0F0', borderBottomWidth: StyleSheet.hairlineWidth,
}, borderBottomColor: colors.divider,
matchedMessageText: { },
flex: 1, matchedMessageText: {
fontSize: 13, flex: 1,
color: '#555', fontSize: 13,
lineHeight: 18, color: colors.text.secondary,
}, lineHeight: 18,
matchedMessageTime: { },
fontSize: 11, matchedMessageTime: {
color: '#AAA', fontSize: 11,
marginLeft: spacing.sm, color: colors.text.hint,
minWidth: 45, marginLeft: spacing.sm,
}, minWidth: 45,
}); },
});
}

View File

@@ -16,11 +16,11 @@ import {
Dimensions, Dimensions,
BackHandler, BackHandler,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native'; import { useIsFocused } from '@react-navigation/native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse } from '../../types/dto'; import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '../../services/messageService'; import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService'; import { commentService } from '../../services/commentService';
@@ -45,6 +45,8 @@ const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'f
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']); const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => { export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
const colors = useAppColors();
const styles = useMemo(() => createNotificationsStyles(colors), [colors]);
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
const router = useRouter(); const router = useRouter();
@@ -554,143 +556,145 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
); );
}; };
const styles = StyleSheet.create({ function createNotificationsStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
// Header 样式 - 美化版 },
header: { // Header 样式 - 美化版
flexDirection: 'row', header: {
alignItems: 'center', flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center',
paddingHorizontal: spacing.lg, justifyContent: 'space-between',
paddingVertical: spacing.md, paddingHorizontal: spacing.lg,
backgroundColor: colors.background.paper, paddingVertical: spacing.md,
...shadows.sm, backgroundColor: colors.background.paper,
}, ...shadows.sm,
headerWide: { },
paddingHorizontal: spacing.xl, headerWide: {
paddingVertical: spacing.lg, paddingHorizontal: spacing.xl,
}, paddingVertical: spacing.lg,
headerTitleContainer: { },
flexDirection: 'row', headerTitleContainer: {
alignItems: 'center', flexDirection: 'row',
flex: 1, alignItems: 'center',
justifyContent: 'center', flex: 1,
}, justifyContent: 'center',
headerTitle: { },
fontSize: 18, headerTitle: {
fontWeight: '600', fontSize: 18,
color: colors.text.primary, fontWeight: '600',
}, color: colors.text.primary,
headerTitleWide: { },
fontSize: 20, headerTitleWide: {
}, fontSize: 20,
unreadBadge: { },
backgroundColor: colors.primary.main, unreadBadge: {
borderRadius: 10, backgroundColor: colors.primary.main,
paddingHorizontal: 6, borderRadius: 10,
paddingVertical: 2, paddingHorizontal: 6,
marginLeft: spacing.sm, paddingVertical: 2,
minWidth: 20, marginLeft: spacing.sm,
alignItems: 'center', minWidth: 20,
justifyContent: 'center', alignItems: 'center',
}, justifyContent: 'center',
unreadBadgeText: { },
color: colors.text.inverse, unreadBadgeText: {
fontSize: 11, color: colors.text.inverse,
fontWeight: '600', fontSize: 11,
}, fontWeight: '600',
backButton: { },
width: 40, backButton: {
height: 40, width: 40,
justifyContent: 'center', height: 40,
alignItems: 'flex-start', justifyContent: 'center',
}, alignItems: 'flex-start',
backButtonPlaceholder: { },
width: 40, backButtonPlaceholder: {
}, width: 40,
// 分类筛选 - 美化版(使用分段式设计) },
filterContainer: { // 分类筛选 - 美化版(使用分段式设计)
flexDirection: 'row', filterContainer: {
paddingHorizontal: spacing.md, flexDirection: 'row',
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
backgroundColor: colors.background.paper, paddingVertical: spacing.sm,
}, backgroundColor: colors.background.paper,
filterContainerWideWeb: { },
paddingHorizontal: spacing.xl, filterContainerWideWeb: {
paddingVertical: spacing.md, paddingHorizontal: spacing.xl,
justifyContent: 'center', paddingVertical: spacing.md,
backgroundColor: colors.background.paper, justifyContent: 'center',
}, backgroundColor: colors.background.paper,
filterTag: { },
flexDirection: 'row', filterTag: {
alignItems: 'center', flexDirection: 'row',
paddingHorizontal: spacing.md, alignItems: 'center',
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
marginRight: spacing.xs, paddingVertical: spacing.sm,
borderRadius: borderRadius.lg, marginRight: spacing.xs,
backgroundColor: colors.background.default, borderRadius: borderRadius.lg,
borderWidth: 1, backgroundColor: colors.background.default,
borderColor: 'transparent', borderWidth: 1,
}, borderColor: 'transparent',
filterTagWide: { },
paddingHorizontal: spacing.lg, filterTagWide: {
paddingVertical: spacing.md, paddingHorizontal: spacing.lg,
marginRight: spacing.sm, paddingVertical: spacing.md,
}, marginRight: spacing.sm,
filterTagActive: { },
backgroundColor: colors.primary.main, filterTagActive: {
borderColor: colors.primary.main, backgroundColor: colors.primary.main,
}, borderColor: colors.primary.main,
filterTagTextActive: { },
fontWeight: '600', filterTagTextActive: {
}, fontWeight: '600',
filterCount: { },
marginLeft: spacing.xs, filterCount: {
backgroundColor: colors.primary.light + '40', marginLeft: spacing.xs,
paddingHorizontal: 6, backgroundColor: colors.primary.light + '40',
paddingVertical: 1, paddingHorizontal: 6,
borderRadius: 8, paddingVertical: 1,
minWidth: 18, borderRadius: 8,
alignItems: 'center', minWidth: 18,
}, alignItems: 'center',
filterCountActive: { },
backgroundColor: 'rgba(255, 255, 255, 0.25)', filterCountActive: {
}, backgroundColor: 'rgba(255, 255, 255, 0.25)',
listContent: { },
flexGrow: 1, listContent: {
paddingTop: spacing.md, flexGrow: 1,
paddingBottom: spacing.lg, paddingTop: spacing.md,
}, paddingBottom: spacing.lg,
listContentWide: { },
paddingHorizontal: spacing.xl, listContentWide: {
}, paddingHorizontal: spacing.xl,
loadingContainer: { },
flex: 1, loadingContainer: {
justifyContent: 'center', flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: spacing.xl * 2, alignItems: 'center',
}, paddingVertical: spacing.xl * 2,
loadingMore: { },
flexDirection: 'row', loadingMore: {
justifyContent: 'center', flexDirection: 'row',
alignItems: 'center', justifyContent: 'center',
paddingVertical: spacing.lg, alignItems: 'center',
}, paddingVertical: spacing.lg,
loadingMoreText: { },
marginLeft: spacing.sm, loadingMoreText: {
}, marginLeft: spacing.sm,
emptyContainer: { },
flex: 1, emptyContainer: {
justifyContent: 'center', flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingTop: spacing['3xl'], alignItems: 'center',
}, paddingTop: spacing['3xl'],
emptyContainerWeb: { },
minHeight: 500, emptyContainerWeb: {
justifyContent: 'center', minHeight: 500,
paddingTop: 100, justifyContent: 'center',
}, paddingTop: 100,
}); },
});
}

View File

@@ -4,7 +4,7 @@
* 参考QQ和微信的实现提供聊天管理功能 * 参考QQ和微信的实现提供聊天管理功能
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -16,7 +16,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { authService } from '../../services/authService'; import { authService } from '../../services/authService';
import { messageService } from '../../services/messageService'; import { messageService } from '../../services/messageService';
@@ -33,6 +33,8 @@ import * as hrefs from '../../navigation/hrefs';
import { AppBackButton } from '../../components/common'; import { AppBackButton } from '../../components/common';
const PrivateChatInfoScreen: React.FC = () => { const PrivateChatInfoScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createPrivateChatInfoStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const raw = useLocalSearchParams<{ const raw = useLocalSearchParams<{
conversationId?: string; conversationId?: string;
@@ -277,7 +279,7 @@ const PrivateChatInfoScreen: React.FC = () => {
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
trackColor={{ false: '#E0E0E0', true: colors.primary.light }} trackColor={{ false: '#E0E0E0', true: colors.primary.light }}
thumbColor={value ? colors.primary.main : '#F5F5F5'} thumbColor={value ? colors.primary.main : colors.background.default}
/> />
</View> </View>
); );
@@ -420,101 +422,103 @@ const PrivateChatInfoScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createPrivateChatInfoStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
pageHeader: { },
flexDirection: 'row', pageHeader: {
alignItems: 'center', flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center',
paddingHorizontal: spacing.md, justifyContent: 'space-between',
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
backgroundColor: colors.background.paper, paddingVertical: spacing.sm,
borderBottomWidth: 1, backgroundColor: colors.background.paper,
borderBottomColor: colors.divider, borderBottomWidth: 1,
}, borderBottomColor: colors.divider,
pageTitle: { },
fontWeight: '600', pageTitle: {
}, fontWeight: '600',
pageHeaderPlaceholder: { },
width: 40, pageHeaderPlaceholder: {
height: 40, width: 40,
}, height: 40,
scrollView: { },
flex: 1, scrollView: {
}, flex: 1,
scrollContent: { },
paddingBottom: spacing.xl, scrollContent: {
}, paddingBottom: spacing.xl,
headerCard: { },
backgroundColor: colors.background.paper, headerCard: {
marginHorizontal: spacing.md, backgroundColor: colors.background.paper,
marginTop: spacing.md, marginHorizontal: spacing.md,
borderRadius: borderRadius.lg, marginTop: spacing.md,
padding: spacing.lg, borderRadius: borderRadius.lg,
...shadows.sm, padding: spacing.lg,
}, ...shadows.sm,
userHeader: { },
flexDirection: 'row', userHeader: {
alignItems: 'center', flexDirection: 'row',
}, alignItems: 'center',
userInfo: { },
flex: 1, userInfo: {
marginLeft: spacing.md, flex: 1,
}, marginLeft: spacing.md,
userName: { },
fontWeight: '600', userName: {
marginBottom: spacing.xs, fontWeight: '600',
}, marginBottom: spacing.xs,
section: { },
marginTop: spacing.lg, section: {
paddingHorizontal: spacing.md, marginTop: spacing.lg,
}, paddingHorizontal: spacing.md,
sectionTitle: { },
marginBottom: spacing.sm, sectionTitle: {
marginLeft: spacing.sm, marginBottom: spacing.sm,
fontWeight: '500', marginLeft: spacing.sm,
}, fontWeight: '500',
card: { },
backgroundColor: colors.background.paper, card: {
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
...shadows.sm, borderRadius: borderRadius.lg,
overflow: 'hidden', ...shadows.sm,
}, overflow: 'hidden',
settingItem: { },
flexDirection: 'row', settingItem: {
alignItems: 'center', flexDirection: 'row',
paddingVertical: spacing.sm, alignItems: 'center',
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
}, paddingHorizontal: spacing.md,
settingIconContainer: { },
width: 36, settingIconContainer: {
height: 36, width: 36,
borderRadius: borderRadius.md, height: 36,
backgroundColor: colors.primary.light + '20', borderRadius: borderRadius.md,
justifyContent: 'center', backgroundColor: colors.primary.light + '20',
alignItems: 'center', justifyContent: 'center',
marginRight: spacing.md, alignItems: 'center',
}, marginRight: spacing.md,
settingIconDanger: { },
backgroundColor: colors.error.light + '20', settingIconDanger: {
}, backgroundColor: colors.error.light + '20',
settingTitle: { },
flex: 1, settingTitle: {
}, flex: 1,
dangerText: { },
color: colors.error.main, dangerText: {
}, color: colors.error.main,
divider: { },
marginLeft: spacing.xl + 36, divider: {
width: 'auto', marginLeft: spacing.xl + 36,
marginVertical: spacing.xs, width: 'auto',
}, marginVertical: spacing.xs,
deleteButton: { },
marginTop: spacing.sm, deleteButton: {
}, marginTop: spacing.sm,
}); },
});
}
export default PrivateChatInfoScreen; export default PrivateChatInfoScreen;

View File

@@ -7,8 +7,8 @@ import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text, AppBackButton } from '../../../../components/common'; import { Avatar, Text, AppBackButton } from '../../../../components/common';
import { colors, spacing } from '../../../../theme'; import { useAppColors, spacing } from '../../../../theme';
import { chatScreenStyles as baseStyles } from './styles'; import { useChatScreenStyles } from './styles';
import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { useBreakpointGTE } from '../../../../hooks/useResponsive';
import { ChatHeaderProps } from './types'; import { ChatHeaderProps } from './types';
@@ -24,6 +24,9 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onGroupInfoPress, onGroupInfoPress,
isWideScreen: propIsWideScreen, isWideScreen: propIsWideScreen,
}) => { }) => {
const colors = useAppColors();
const baseStyles = useChatScreenStyles();
// 响应式布局 // 响应式布局
// 使用 768px 作为大屏幕和小屏幕的分界线 // 使用 768px 作为大屏幕和小屏幕的分界线
const isWideScreenHook = useBreakpointGTE('lg'); const isWideScreenHook = useBreakpointGTE('lg');
@@ -57,7 +60,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
fontSize: isWideScreen ? 13 : 12, fontSize: isWideScreen ? 13 : 12,
}, },
}); });
}, [isWideScreen]); }, [isWideScreen, baseStyles]);
// 获取显示标题 // 获取显示标题
const getDisplayTitle = () => { const getDisplayTitle = () => {

View File

@@ -7,8 +7,8 @@ import React, { useMemo } from 'react';
import { View, TouchableOpacity, TextInput, ActivityIndicator, ScrollView, StyleSheet, Image as RNImage } from 'react-native'; import { View, TouchableOpacity, TextInput, ActivityIndicator, ScrollView, StyleSheet, Image as RNImage } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '../../../../components/common'; import { Text } from '../../../../components/common';
import { colors } from '../../../../theme'; import { useAppColors } from '../../../../theme';
import { chatScreenStyles as baseStyles } from './styles'; import { useChatScreenStyles } from './styles';
import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { useBreakpointGTE } from '../../../../hooks/useResponsive';
import { ChatInputProps, GroupMessage, SenderInfo } from './types'; import { ChatInputProps, GroupMessage, SenderInfo } from './types';
import { extractTextFromSegments } from '../../../../types/dto'; import { extractTextFromSegments } from '../../../../types/dto';
@@ -39,14 +39,15 @@ export const ChatInput: React.FC<ChatInputProps & {
otherUser, otherUser,
getSenderInfo, getSenderInfo,
}) => { }) => {
const colors = useAppColors();
const styles = useChatScreenStyles();
const isDisabled = isGroupChat && isMuted; const isDisabled = isGroupChat && isMuted;
// 获取当前用户ID // 获取当前用户ID
const currentUserId = currentUser?.id || ''; const currentUserId = currentUser?.id || '';
// 响应式布局 // 响应式布局
const isWideScreen = useBreakpointGTE('lg'); const isWideScreen = useBreakpointGTE('lg');
const styles = baseStyles;
const inputContainerStyle = useMemo(() => ([ const inputContainerStyle = useMemo(() => ([
styles.inputContainer, styles.inputContainer,

View File

@@ -10,11 +10,11 @@ import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { Text } from '../../../../components/common'; import { Text } from '../../../../components/common';
import { chatScreenStyles as baseStyles } from './styles'; import { useChatScreenStyles } from './styles';
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
import { EMOJIS } from './constants'; import { EMOJIS } from './constants';
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService'; import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
import { colors, spacing } from '../../../../theme'; import { useAppColors, spacing } from '../../../../theme';
const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const { height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -41,6 +41,8 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
onInsertSticker, onInsertSticker,
onClose, onClose,
}) => { }) => {
const colors = useAppColors();
const baseStyles = useChatScreenStyles();
const [activeTab, setActiveTab] = useState<TabType>('emoji'); const [activeTab, setActiveTab] = useState<TabType>('emoji');
const [stickers, setStickers] = useState<CustomSticker[]>([]); const [stickers, setStickers] = useState<CustomSticker[]>([]);
const [loadingStickers, setLoadingStickers] = useState(false); const [loadingStickers, setLoadingStickers] = useState(false);
@@ -82,7 +84,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
lineHeight: emojiSize.size + 6, lineHeight: emojiSize.size + 6,
}, },
}); });
}, [emojiSize, emojiColumns]); }, [emojiSize, emojiColumns, baseStyles]);
// 加载自定义表情 // 加载自定义表情
const loadStickers = useCallback(async () => { const loadStickers = useCallback(async () => {

View File

@@ -5,7 +5,7 @@
* 实现手机端群信息界面的核心功能 * 实现手机端群信息界面的核心功能
*/ */
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -17,7 +17,7 @@ import {
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text } from '../../../../components/common'; import { Avatar, Text } from '../../../../components/common';
import { colors, spacing, fontSizes, shadows } from '../../../../theme'; import { useAppColors, spacing, fontSizes, shadows, type AppColors } from '../../../../theme';
import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto'; import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto';
import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { useBreakpointGTE } from '../../../../hooks/useResponsive';
import { groupManager } from '../../../../stores/groupManager'; import { groupManager } from '../../../../stores/groupManager';
@@ -53,6 +53,8 @@ export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
onInviteMembers, onInviteMembers,
onViewAllMembers, onViewAllMembers,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createGroupInfoPanelStyles(colors), [colors]);
const isWideScreen = useBreakpointGTE('lg'); const isWideScreen = useBreakpointGTE('lg');
const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current; const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current;
const fadeAnim = React.useRef(new Animated.Value(0)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current;
@@ -363,7 +365,8 @@ export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
); );
}; };
const styles = StyleSheet.create({ function createGroupInfoPanelStyles(colors: AppColors) {
return StyleSheet.create({
overlay: { overlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)', backgroundColor: 'rgba(0, 0, 0, 0.3)',
@@ -378,7 +381,7 @@ const styles = StyleSheet.create({
top: 0, top: 0,
bottom: 0, bottom: 0,
width: PANEL_WIDTH, width: PANEL_WIDTH,
backgroundColor: '#FFF', backgroundColor: colors.background.paper,
zIndex: 101, zIndex: 101,
...shadows.lg, ...shadows.lg,
}, },
@@ -389,12 +392,12 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#E8E8E8', borderBottomColor: colors.divider,
}, },
headerTitle: { headerTitle: {
fontSize: fontSizes.xl, fontSize: fontSizes.xl,
fontWeight: '600', fontWeight: '600',
color: '#333', color: colors.text.primary,
}, },
closeButton: { closeButton: {
padding: spacing.xs, padding: spacing.xs,
@@ -409,12 +412,12 @@ const styles = StyleSheet.create({
groupName: { groupName: {
fontSize: fontSizes.xl, fontSize: fontSizes.xl,
fontWeight: '600', fontWeight: '600',
color: '#333', color: colors.text.primary,
marginTop: spacing.md, marginTop: spacing.md,
}, },
memberCount: { memberCount: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: '#999', color: colors.text.secondary,
marginTop: spacing.xs, marginTop: spacing.xs,
}, },
joinType: { joinType: {
@@ -442,7 +445,7 @@ const styles = StyleSheet.create({
}, },
divider: { divider: {
height: 1, height: 1,
backgroundColor: '#E8E8E8', backgroundColor: colors.divider,
marginHorizontal: spacing.md, marginHorizontal: spacing.md,
}, },
section: { section: {
@@ -456,11 +459,11 @@ const styles = StyleSheet.create({
sectionTitle: { sectionTitle: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
fontWeight: '600', fontWeight: '600',
color: '#333', color: colors.text.primary,
marginLeft: spacing.xs, marginLeft: spacing.xs,
}, },
noticeBox: { noticeBox: {
backgroundColor: '#FFF9E6', backgroundColor: colors.chat.tipBg,
padding: spacing.md, padding: spacing.md,
borderRadius: 8, borderRadius: 8,
borderLeftWidth: 3, borderLeftWidth: 3,
@@ -468,23 +471,23 @@ const styles = StyleSheet.create({
}, },
noticeText: { noticeText: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: '#666', color: colors.text.secondary,
lineHeight: 20, lineHeight: 20,
}, },
noticeTime: { noticeTime: {
fontSize: fontSizes.xs, fontSize: fontSizes.xs,
color: '#999', color: colors.text.hint,
marginTop: spacing.xs, marginTop: spacing.xs,
textAlign: 'right', textAlign: 'right',
}, },
descriptionBox: { descriptionBox: {
backgroundColor: '#F5F7FA', backgroundColor: colors.chat.surfaceMuted,
padding: spacing.md, padding: spacing.md,
borderRadius: 8, borderRadius: 8,
}, },
description: { description: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: '#666', color: colors.text.secondary,
lineHeight: 20, lineHeight: 20,
}, },
memberList: { memberList: {
@@ -518,7 +521,7 @@ const styles = StyleSheet.create({
}, },
memberName: { memberName: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: '#333', color: colors.text.primary,
flex: 1, flex: 1,
}, },
ownerBadge: { ownerBadge: {
@@ -541,7 +544,7 @@ const styles = StyleSheet.create({
}, },
moreMembers: { moreMembers: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: '#999', color: colors.text.secondary,
textAlign: 'center', textAlign: 'center',
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
}, },
@@ -550,15 +553,15 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#F0F0F0', borderBottomColor: colors.chat.borderHairline,
}, },
infoLabel: { infoLabel: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: '#999', color: colors.text.secondary,
}, },
infoValue: { infoValue: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: '#333', color: colors.text.primary,
}, },
actionSection: { actionSection: {
padding: spacing.md, padding: spacing.md,
@@ -588,6 +591,7 @@ const styles = StyleSheet.create({
leaveButtonText: { leaveButtonText: {
color: colors.error.main, color: colors.error.main,
}, },
}); });
}
export default GroupInfoPanel; export default GroupInfoPanel;

View File

@@ -15,7 +15,7 @@ import {
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { chatScreenStyles as styles } from './styles'; import { useChatScreenStyles } from './styles';
import { LongPressMenuProps } from './types'; import { LongPressMenuProps } from './types';
import { RECALL_TIME_LIMIT } from './constants'; import { RECALL_TIME_LIMIT } from './constants';
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto'; import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
@@ -34,6 +34,7 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
onDelete, onDelete,
onAddSticker, onAddSticker,
}) => { }) => {
const styles = useChatScreenStyles();
const scaleAnimation = useRef(new Animated.Value(0)).current; const scaleAnimation = useRef(new Animated.Value(0)).current;
const opacityAnimation = useRef(new Animated.Value(0)).current; const opacityAnimation = useRef(new Animated.Value(0)).current;
const blurActiveElementOnWeb = () => { const blurActiveElementOnWeb = () => {

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { View, ScrollView, TouchableOpacity } from 'react-native'; import { View, ScrollView, TouchableOpacity } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text } from '../../../../components/common'; import { Avatar, Text } from '../../../../components/common';
import { chatScreenStyles as styles } from './styles'; import { useChatScreenStyles } from './styles';
import { MentionPanelProps } from './types'; import { MentionPanelProps } from './types';
export const MentionPanel: React.FC<MentionPanelProps> = ({ export const MentionPanel: React.FC<MentionPanelProps> = ({
@@ -18,6 +18,7 @@ export const MentionPanel: React.FC<MentionPanelProps> = ({
onMentionAll, onMentionAll,
onClose, onClose,
}) => { }) => {
const styles = useChatScreenStyles();
// 过滤成员列表 // 过滤成员列表
const filteredMembers = members.filter(member => { const filteredMembers = members.filter(member => {
if (member.user_id === currentUserId) return false; // 排除自己 if (member.user_id === currentUserId) return false; // 排除自己

View File

@@ -15,20 +15,12 @@ import {
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text, ImageGridItem } from '../../../../components/common'; import { Avatar, Text, ImageGridItem } from '../../../../components/common';
import { chatScreenStyles as baseStyles } from './styles'; import { useChatScreenStyles } from './styles';
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types'; import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
import { MessageSegmentsRenderer } from './SegmentRenderer'; import { MessageSegmentsRenderer } from './SegmentRenderer';
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto'; import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
import { SwipeableMessageBubble } from './SwipeableMessageBubble'; import { SwipeableMessageBubble } from './SwipeableMessageBubble';
import {
getBubbleBorderRadius,
getMessageGroupPosition,
shouldShowSenderInfo,
bubbleColors,
bubbleStyles,
} from './bubbleStyles';
// 获取屏幕宽度 // 获取屏幕宽度
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -61,7 +53,8 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
}) => { }) => {
const bubbleRef = useRef<View>(null); const bubbleRef = useRef<View>(null);
const pressPositionRef = useRef({ x: 0, y: 0 }); const pressPositionRef = useRef({ x: 0, y: 0 });
const baseStyles = useChatScreenStyles();
// 响应式布局 // 响应式布局
const { width } = useResponsive(); const { width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg'); const isWideScreen = useBreakpointGTE('lg');
@@ -89,7 +82,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
height: isWideScreen ? 280 : 220, height: isWideScreen ? 280 : 220,
}, },
}); });
}, [maxContentWidth, isWideScreen]); }, [maxContentWidth, isWideScreen, baseStyles]);
// 系统通知消息特殊处理 // 系统通知消息特殊处理
// 支持两种判断方式: // 支持两种判断方式:
@@ -236,26 +229,46 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
} }
}; };
const renderBubbleShell = useCallback(
(
child: React.ReactNode,
innerExtra: (object | undefined | null | false)[] = []
) => (
<View
style={[
styles.messageBubbleOuter,
isMe ? styles.myBubbleOuter : styles.theirBubbleOuter,
]}
>
<View
style={[
styles.messageBubbleInner,
isMe ? styles.myBubbleInner : styles.theirBubbleInner,
...innerExtra.filter(Boolean),
]}
>
{child}
</View>
</View>
),
[isMe, styles]
);
// 渲染消息内容 // 渲染消息内容
const renderMessageContent = () => { const renderMessageContent = () => {
// 撤回消息 // 撤回消息
if (isRecalled) { if (isRecalled) {
return ( return renderBubbleShell(
<View style={[ <Text
styles.messageBubble, style={[
isMe ? styles.myBubble : styles.theirBubble, isMe ? styles.myBubbleText : styles.theirBubbleText,
styles.recalledBubble, styles.recalledText,
]}> ]}
<Text selectable={false}
style={[ >
isMe ? styles.myBubbleText : styles.theirBubbleText,
styles.recalledText, </Text>,
]} [styles.recalledBubble]
selectable={false}
>
</Text>
</View>
); );
} }
@@ -282,71 +295,60 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
// 如果没有 segments显示空内容或错误提示 // 如果没有 segments显示空内容或错误提示
if (!hasSegments) { if (!hasSegments) {
return ( return renderBubbleShell(
<View style={[ <Text
styles.messageBubble, style={[
isMe ? styles.myBubble : styles.theirBubble, isMe ? styles.myBubbleText : styles.theirBubbleText,
]}> { opacity: 0.5 },
<Text ]}
style={[ selectable={false}
isMe ? styles.myBubbleText : styles.theirBubbleText, >
{ opacity: 0.5 }, []
]} </Text>
selectable={false}
>
[]
</Text>
</View>
); );
} }
// 检查是否有回复,用于调整气泡样式 // 检查是否有回复,用于调整气泡样式
const hasReply = segments.some(s => s.type === 'reply'); const hasReply = segments.some(s => s.type === 'reply');
return ( return renderBubbleShell(
<View style={[ <MessageSegmentsRenderer
styles.messageBubble, segments={segments}
isMe ? styles.myBubble : styles.theirBubble, isMe={isMe}
hasReply && segmentStyles.replyBubble, currentUserId={currentUserId}
isPureImageMessage && segmentStyles.pureImageBubble, memberMap={memberMap}
]}> replyMessage={getReplyMessage()}
<MessageSegmentsRenderer getSenderInfo={getSenderInfo}
segments={segments} onAtPress={() => undefined}
isMe={isMe} onReplyPress={onReplyPress}
currentUserId={currentUserId} onImagePress={(url) => {
memberMap={memberMap} // 查找点击的图片索引
replyMessage={getReplyMessage()} const clickIndex = imageSegments.findIndex(
getSenderInfo={getSenderInfo} img => img.url === url || img.thumbnail_url === url
onAtPress={() => undefined} );
onReplyPress={onReplyPress} if (clickIndex !== -1 && onImagePress) {
onImagePress={(url) => { onImagePress(imageSegments, clickIndex);
// 查找点击的图片索引 }
const clickIndex = imageSegments.findIndex( }}
img => img.url === url || img.thumbnail_url === url onImageLongPress={(position?: { x: number; y: number }) => {
); // 图片长按触发和消息气泡一样的菜单
if (clickIndex !== -1 && onImagePress) { // 如果有位置信息,直接使用;否则使用气泡位置
onImagePress(imageSegments, clickIndex); if (position) {
} onLongPress(message, {
}} x: 0,
onImageLongPress={(position?: { x: number; y: number }) => { y: 0,
// 图片长按触发和消息气泡一样的菜单 width: 0,
// 如果有位置信息,直接使用;否则使用气泡位置 height: 0,
if (position) { pressX: position.x,
onLongPress(message, { pressY: position.y,
x: 0, });
y: 0, } else {
width: 0, handleLongPress();
height: 0, }
pressX: position.x, }}
pressY: position.y, onLinkPress={() => undefined}
}); />,
} else { [hasReply && segmentStyles.replyBubble, isPureImageMessage && segmentStyles.pureImageBubble]
handleLongPress();
}
}}
onLinkPress={() => undefined}
/>
</View>
); );
}; };

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { View, TouchableOpacity } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '../../../../components/common'; import { Text } from '../../../../components/common';
import { chatScreenStyles as styles } from './styles'; import { useChatScreenStyles } from './styles';
import { MORE_ACTIONS } from './constants'; import { MORE_ACTIONS } from './constants';
import { MorePanelProps } from './types'; import { MorePanelProps } from './types';
@@ -14,6 +14,7 @@ export const MorePanel: React.FC<MorePanelProps> = ({
onAction, onAction,
disabledActionIds = [], disabledActionIds = [],
}) => { }) => {
const styles = useChatScreenStyles();
const disabledSet = new Set(disabledActionIds); const disabledSet = new Set(disabledActionIds);
return ( return (
<View style={styles.panelContainer}> <View style={styles.panelContainer}>

View File

@@ -31,7 +31,7 @@ import {
UserDTO, UserDTO,
extractTextFromSegments, extractTextFromSegments,
} from '../../../../types/dto'; } from '../../../../types/dto';
import { colors, spacing } from '../../../../theme'; import { spacing, useAppColors, type AppColors } from '../../../../theme';
import { SenderInfo } from './types'; import { SenderInfo } from './types';
const IMAGE_MAX_WIDTH = 200; const IMAGE_MAX_WIDTH = 200;
@@ -142,12 +142,11 @@ export const renderSegment = (
/** /**
* 渲染文本 Segment * 渲染文本 Segment
*/ */
const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNode => { const TextSegmentBody: React.FC<{ data: TextSegmentData; isMe: boolean }> = ({ data, isMe }) => {
// 兼容 content 和 text 两种字段 const styles = useSegmentStyles();
const text = data.content ?? data.text ?? ''; const text = data.content ?? data.text ?? '';
return ( return (
<Text <Text
key={`text-${text.substring(0, 10)}`}
style={[styles.textContent, isMe ? styles.textMe : styles.textOther]} style={[styles.textContent, isMe ? styles.textMe : styles.textOther]}
selectable={false} selectable={false}
> >
@@ -156,6 +155,11 @@ const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNod
); );
}; };
const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNode => {
const key = `text-${(data.content ?? data.text ?? '').substring(0, 10)}`;
return <TextSegmentBody key={key} data={data} isMe={isMe} />;
};
/** /**
* 渲染图片 Segment * 渲染图片 Segment
*/ */
@@ -166,6 +170,7 @@ const ImageSegment: React.FC<{
onImagePress?: (url: string) => void; onImagePress?: (url: string) => void;
onImageLongPress?: (position?: { x: number; y: number }) => void; onImageLongPress?: (position?: { x: number; y: number }) => void;
}> = ({ data, isMe, onImagePress, onImageLongPress }) => { }> = ({ data, isMe, onImagePress, onImageLongPress }) => {
const styles = useSegmentStyles();
const pressPositionRef = useRef({ x: 0, y: 0 }); const pressPositionRef = useRef({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
@@ -362,16 +367,13 @@ const renderImageSegment = (
/** /**
* 渲染 @ Segment * 渲染 @ Segment
*/ */
const renderAtSegment = ( const AtSegmentBody: React.FC<{
data: AtSegmentData, data: AtSegmentData;
props: Omit<SegmentRendererProps, 'segment'> props: Omit<SegmentRendererProps, 'segment'>;
): React.ReactNode => { }> = ({ data, props }) => {
const styles = useSegmentStyles();
const { currentUserId, memberMap, onAtPress, isMe } = props; const { currentUserId, memberMap, onAtPress, isMe } = props;
// 判断是否@当前用户
const isAtMe = data.user_id === currentUserId || data.user_id === 'all'; const isAtMe = data.user_id === currentUserId || data.user_id === 'all';
// 优先从 memberMap 中查找昵称,兜底使用 data.nickname兼容旧消息再兜底显示"用户"
let displayName: string; let displayName: string;
if (data.user_id === 'all') { if (data.user_id === 'all') {
displayName = '所有人'; displayName = '所有人';
@@ -379,10 +381,8 @@ const renderAtSegment = (
const member = memberMap?.get(data.user_id); const member = memberMap?.get(data.user_id);
displayName = member?.nickname || data.nickname || '用户'; displayName = member?.nickname || data.nickname || '用户';
} }
return ( return (
<Text <Text
key={`at-${data.user_id}`}
style={[ style={[
styles.atText, styles.atText,
isMe ? styles.atTextMe : styles.atTextOther, isMe ? styles.atTextMe : styles.atTextOther,
@@ -395,15 +395,19 @@ const renderAtSegment = (
); );
}; };
const renderAtSegment = (
data: AtSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => <AtSegmentBody key={`at-${data.user_id}`} data={data} props={props} />;
/** /**
* 渲染表情 Segment * 渲染表情 Segment
*/ */
const renderFaceSegment = (data: FaceSegmentData, isMe: boolean): React.ReactNode => { const FaceSegmentBody: React.FC<{ data: FaceSegmentData }> = ({ data }) => {
// 如果有自定义表情URL显示图片 const styles = useSegmentStyles();
if (data.url) { if (data.url) {
return ( return (
<ExpoImage <ExpoImage
key={`face-${data.id}`}
source={{ uri: data.url }} source={{ uri: data.url }}
style={styles.faceImage} style={styles.faceImage}
contentFit="cover" contentFit="cover"
@@ -411,29 +415,32 @@ const renderFaceSegment = (data: FaceSegmentData, isMe: boolean): React.ReactNod
/> />
); );
} }
// 否则显示表情名称(后续可以映射到本地表情)
return ( return (
<Text key={`face-${data.id}`} style={styles.faceText}> <Text style={styles.faceText}>
[{data.name || `表情${data.id}`}] [{data.name || `表情${data.id}`}]
</Text> </Text>
); );
}; };
const renderFaceSegment = (data: FaceSegmentData, _isMe: boolean): React.ReactNode => (
<FaceSegmentBody key={`face-${data.id}`} data={data} />
);
/** /**
* 渲染语音 Segment * 渲染语音 Segment
*/ */
const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => { const VoiceSegmentBody: React.FC<{ data: VoiceSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const styles = useSegmentStyles();
const themeColors = useAppColors();
return ( return (
<TouchableOpacity <TouchableOpacity
key={`voice-${data.url}`}
style={[styles.voiceContainer, isMe ? styles.voiceMe : styles.voiceOther]} style={[styles.voiceContainer, isMe ? styles.voiceMe : styles.voiceOther]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<MaterialCommunityIcons <MaterialCommunityIcons
name="microphone" name="microphone"
size={20} size={20}
color={isMe ? '#FFFFFF' : '#666666'} color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
/> />
<Text style={[styles.voiceDuration, isMe ? styles.textMe : styles.textOther]}> <Text style={[styles.voiceDuration, isMe ? styles.textMe : styles.textOther]}>
{data.duration ? `${data.duration}"` : '语音'} {data.duration ? `${data.duration}"` : '语音'}
@@ -442,10 +449,16 @@ const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactN
); );
}; };
const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => (
<VoiceSegmentBody key={`voice-${data.url}`} data={data} isMe={isMe} />
);
/** /**
* 视频 Segment 组件 - 支持点击播放 * 视频 Segment 组件 - 支持点击播放
*/ */
const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ data, isMe }) => { const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const styles = useSegmentStyles();
const themeColors = useAppColors();
const [playerVisible, setPlayerVisible] = useState(false); const [playerVisible, setPlayerVisible] = useState(false);
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
@@ -469,11 +482,11 @@ const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ dat
/> />
) : ( ) : (
<View style={styles.videoPlaceholder}> <View style={styles.videoPlaceholder}>
<MaterialCommunityIcons name="video" size={32} color="#FFFFFF" /> <MaterialCommunityIcons name="video" size={32} color={themeColors.primary.contrast} />
</View> </View>
)} )}
<View style={styles.videoPlayIcon}> <View style={styles.videoPlayIcon}>
<MaterialCommunityIcons name="play-circle" size={40} color="#FFFFFF" /> <MaterialCommunityIcons name="play-circle" size={40} color={themeColors.primary.contrast} />
</View> </View>
{data.duration && ( {data.duration && (
<Text style={styles.videoDuration}>{formatDuration(data.duration)}</Text> <Text style={styles.videoDuration}>{formatDuration(data.duration)}</Text>
@@ -496,12 +509,12 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
/** /**
* 渲染文件 Segment * 渲染文件 Segment
*/ */
const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => { const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const styles = useSegmentStyles();
const themeColors = useAppColors();
const fileSize = data.size ? formatFileSize(data.size) : ''; const fileSize = data.size ? formatFileSize(data.size) : '';
return ( return (
<TouchableOpacity <TouchableOpacity
key={`file-${data.url}`}
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]} style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -509,33 +522,36 @@ const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNod
<MaterialCommunityIcons <MaterialCommunityIcons
name="file-document" name="file-document"
size={28} size={28}
color={isMe ? '#FFFFFF' : colors.primary.main} color={isMe ? themeColors.primary.contrast : themeColors.primary.main}
/> />
</View> </View>
<View style={styles.fileInfo}> <View style={styles.fileInfo}>
<Text <Text
style={[styles.fileName, isMe ? styles.textMe : styles.textOther]} style={[styles.fileName, isMe ? styles.textMe : styles.textOther]}
numberOfLines={1} numberOfLines={1}
> >
{data.name} {data.name}
</Text> </Text>
{fileSize && ( {fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null}
<Text style={styles.fileSize}>{fileSize}</Text>
)}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => (
<FileSegmentBody key={`file-${data.url}`} data={data} isMe={isMe} />
);
/** /**
* 渲染链接 Segment * 渲染链接 Segment
*/ */
const renderLinkSegment = ( const LinkSegmentBody: React.FC<{
data: LinkSegmentData, data: LinkSegmentData;
props: Omit<SegmentRendererProps, 'segment'> props: Omit<SegmentRendererProps, 'segment'>;
): React.ReactNode => { }> = ({ data, props }) => {
const styles = useSegmentStyles();
const { onLinkPress, isMe } = props; const { onLinkPress, isMe } = props;
const handlePress = async () => { const handlePress = async () => {
if (onLinkPress) { if (onLinkPress) {
onLinkPress(data.url); onLinkPress(data.url);
@@ -552,26 +568,25 @@ const renderLinkSegment = (
} }
} }
}; };
return ( return (
<TouchableOpacity <TouchableOpacity
key={`link-${data.url}`}
style={[styles.linkContainer, isMe ? styles.linkMe : styles.linkOther]} style={[styles.linkContainer, isMe ? styles.linkMe : styles.linkOther]}
onPress={handlePress} onPress={handlePress}
activeOpacity={0.7} activeOpacity={0.7}
> >
{data.image && ( {data.image ? (
<ExpoImage source={{ uri: data.image }} style={styles.linkImage} contentFit="cover" cachePolicy="memory" /> <ExpoImage source={{ uri: data.image }} style={styles.linkImage} contentFit="cover" cachePolicy="memory" />
)} ) : null}
<View style={styles.linkContent}> <View style={styles.linkContent}>
<Text style={styles.linkTitle} numberOfLines={2}> <Text style={styles.linkTitle} numberOfLines={2}>
{data.title || data.url} {data.title || data.url}
</Text> </Text>
{data.description && ( {data.description ? (
<Text style={styles.linkDescription} numberOfLines={2}> <Text style={styles.linkDescription} numberOfLines={2}>
{data.description} {data.description}
</Text> </Text>
)} ) : null}
<Text style={styles.linkUrl} numberOfLines={1}> <Text style={styles.linkUrl} numberOfLines={1}>
{(() => { {(() => {
try { try {
@@ -586,6 +601,11 @@ const renderLinkSegment = (
); );
}; };
const renderLinkSegment = (
data: LinkSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => <LinkSegmentBody key={`link-${data.url}`} data={data} props={props} />;
/** /**
* 回复预览组件(用于消息气泡内显示回复引用) * 回复预览组件(用于消息气泡内显示回复引用)
*/ */
@@ -596,6 +616,9 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
onPress, onPress,
getSenderInfo, getSenderInfo,
}) => { }) => {
const themeColors = useAppColors();
const styles = useMemo(() => createSegmentStyles(themeColors), [themeColors]);
if (!replyMessage) { if (!replyMessage) {
// 如果没有引用消息详情只显示引用ID // 如果没有引用消息详情只显示引用ID
return ( return (
@@ -605,9 +628,11 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.replyLine} /> <View style={styles.replyLine} />
<Text style={styles.replyText} numberOfLines={2}> <View style={styles.replyContent}>
<Text style={styles.replyText} numberOfLines={2}>
</Text>
</Text>
</View>
</TouchableOpacity> </TouchableOpacity>
); );
} }
@@ -658,6 +683,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
onPress={onPress} onPress={onPress}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.replyLine} />
<View style={styles.replyContent}> <View style={styles.replyContent}>
<Text style={styles.replySender} numberOfLines={1}> <Text style={styles.replySender} numberOfLines={1}>
{senderName}: {senderName}:
@@ -702,7 +728,9 @@ export const MessageSegmentsRenderer: React.FC<{
onLinkPress, onLinkPress,
getSenderInfo, getSenderInfo,
}) => { }) => {
// 找出 reply segment如果有 const themeColors = useAppColors();
const segStyles = useMemo(() => createSegmentStyles(themeColors), [themeColors]);
const replySegment = segments.find(s => s.type === 'reply'); const replySegment = segments.find(s => s.type === 'reply');
const otherSegments = segments.filter(s => s.type !== 'reply'); const otherSegments = segments.filter(s => s.type !== 'reply');
const chunks = partitionMessageSegments(otherSegments); const chunks = partitionMessageSegments(otherSegments);
@@ -717,58 +745,58 @@ export const MessageSegmentsRenderer: React.FC<{
onImageLongPress, onImageLongPress,
onLinkPress, onLinkPress,
}; };
return ( return (
<View style={styles.segmentsContainer}> <SegmentStylesContext.Provider value={segStyles}>
{/* 回复预览 */} <View style={segStyles.segmentsContainer}>
{replySegment && ( {replySegment ? (
<ReplyPreviewSegment <ReplyPreviewSegment
replyData={(replySegment.data as ReplySegmentData)} replyData={replySegment.data as ReplySegmentData}
replyMessage={replyMessage} replyMessage={replyMessage}
isMe={isMe} isMe={isMe}
onPress={() => onReplyPress?.((replySegment.data as ReplySegmentData).id)} onPress={() => onReplyPress?.((replySegment.data as ReplySegmentData).id)}
getSenderInfo={getSenderInfo} getSenderInfo={getSenderInfo}
/> />
)} ) : null}
{/* 其他 Segment行内文案与块级图片/媒体分行展示,多图纵向排列 */} <View style={segStyles.segmentsColumn}>
<View style={styles.segmentsColumn}> {chunks.map((chunk, i) => {
{chunks.map((chunk, i) => { if (chunk.kind === 'inline') {
if (chunk.kind === 'inline') { return (
<View key={`inl-${i}`} style={segStyles.inlineChunk}>
{chunk.parts.map((segment, j) => (
<React.Fragment
key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || j}`}
>
{renderSegment(segment, renderProps)}
</React.Fragment>
))}
</View>
);
}
if (chunk.kind === 'images') {
return (
<View key={`imgs-${i}`} style={segStyles.imagesChunk}>
{chunk.parts.map((segment, j) => (
<View
key={`img-${(segment.data as ImageSegmentData)?.url || j}`}
style={j < chunk.parts.length - 1 ? segStyles.imageStackItem : segStyles.imageStackItemLast}
>
{renderSegment(segment, renderProps)}
</View>
))}
</View>
);
}
return ( return (
<View key={`inl-${i}`} style={styles.inlineChunk}> <View key={`blk-${i}`} style={segStyles.blockChunk}>
{chunk.parts.map((segment, j) => ( {renderSegment(chunk.segment, renderProps)}
<React.Fragment
key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || j}`}
>
{renderSegment(segment, renderProps)}
</React.Fragment>
))}
</View> </View>
); );
} })}
if (chunk.kind === 'images') { </View>
return (
<View key={`imgs-${i}`} style={styles.imagesChunk}>
{chunk.parts.map((segment, j) => (
<View
key={`img-${(segment.data as ImageSegmentData)?.url || j}`}
style={j < chunk.parts.length - 1 ? styles.imageStackItem : styles.imageStackItemLast}
>
{renderSegment(segment, renderProps)}
</View>
))}
</View>
);
}
return (
<View key={`blk-${i}`} style={styles.blockChunk}>
{renderSegment(chunk.segment, renderProps)}
</View>
);
})}
</View> </View>
</View> </SegmentStylesContext.Provider>
); );
}; };
@@ -787,7 +815,8 @@ const formatDuration = (seconds: number): string => {
return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`; return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`;
}; };
const styles = StyleSheet.create({ function createSegmentStyles(colors: AppColors) {
return StyleSheet.create({
// 容器 // 容器
segmentsContainer: { segmentsContainer: {
flexDirection: 'column', flexDirection: 'column',
@@ -826,10 +855,10 @@ const styles = StyleSheet.create({
fontWeight: '400', fontWeight: '400',
}, },
textMe: { textMe: {
color: '#1A1A1A', // 统一使用深色字体 color: colors.chat.textPrimary,
}, },
textOther: { textOther: {
color: '#1A1A1A', // 统一使用深色字体 color: colors.chat.textPrimary,
}, },
// @提及 - Telegram风格蓝色高亮 // @提及 - Telegram风格蓝色高亮
@@ -845,7 +874,7 @@ const styles = StyleSheet.create({
borderRadius: 4, borderRadius: 4,
}, },
atTextOther: { atTextOther: {
color: '#4A88C7', color: colors.chat.link,
backgroundColor: 'rgba(74, 136, 199, 0.12)', backgroundColor: 'rgba(74, 136, 199, 0.12)',
borderRadius: 4, borderRadius: 4,
}, },
@@ -860,7 +889,7 @@ const styles = StyleSheet.create({
borderRadius: 12, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
marginTop: 4, marginTop: 4,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15, shadowOpacity: 0.15,
shadowRadius: 4, shadowRadius: 4,
@@ -892,7 +921,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm + 2, paddingVertical: spacing.sm + 2,
borderRadius: 20, borderRadius: 20,
minWidth: 100, minWidth: 100,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 2, shadowRadius: 2,
@@ -916,7 +945,7 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
width: 220, width: 220,
height: 165, height: 165,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15, shadowOpacity: 0.15,
shadowRadius: 4, shadowRadius: 4,
@@ -953,7 +982,7 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
bottom: 10, bottom: 10,
right: 10, right: 10,
color: '#FFFFFF', color: colors.primary.contrast,
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
backgroundColor: 'rgba(0, 0, 0, 0.6)', backgroundColor: 'rgba(0, 0, 0, 0.6)',
@@ -971,7 +1000,7 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
minWidth: 220, minWidth: 220,
maxWidth: 300, maxWidth: 300,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 2, shadowRadius: 2,
@@ -981,7 +1010,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.25)', backgroundColor: 'rgba(255, 255, 255, 0.25)',
}, },
fileOther: { fileOther: {
backgroundColor: '#F8F9FA', backgroundColor: colors.chat.surfaceRaised,
}, },
fileIcon: { fileIcon: {
marginRight: spacing.md, marginRight: spacing.md,
@@ -998,11 +1027,11 @@ const styles = StyleSheet.create({
fileName: { fileName: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#1A1A1A', color: colors.chat.textPrimary,
}, },
fileSize: { fileSize: {
fontSize: 13, fontSize: 13,
color: '#8E8E93', color: colors.chat.textSecondary,
marginTop: 4, marginTop: 4,
fontWeight: '500', fontWeight: '500',
}, },
@@ -1012,8 +1041,8 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
maxWidth: 280, maxWidth: 280,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 4, shadowRadius: 4,
@@ -1023,7 +1052,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
}, },
linkOther: { linkOther: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
}, },
linkImage: { linkImage: {
width: '100%', width: '100%',
@@ -1035,12 +1064,12 @@ const styles = StyleSheet.create({
linkTitle: { linkTitle: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#1A1A1A', color: colors.chat.textPrimary,
lineHeight: 20, lineHeight: 20,
}, },
linkDescription: { linkDescription: {
fontSize: 13, fontSize: 13,
color: '#666666', color: colors.chat.textTertiary,
marginTop: 6, marginTop: 6,
lineHeight: 18, lineHeight: 18,
}, },
@@ -1051,16 +1080,17 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
}, },
// 回复预览 - Telegram风格简洁设计 // 回复预览 - Telegram风格左侧色条 + 简洁背景
replyPreview: { replyPreview: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center',
marginBottom: 0, marginBottom: 0,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm + 2, paddingHorizontal: spacing.sm + 2,
borderRadius: 6, borderRadius: 6,
width: '100%', width: '100%',
minWidth: 200, minWidth: 200,
// 移除阴影 gap: 8,
shadowColor: 'transparent', shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0, shadowOpacity: 0,
@@ -1074,28 +1104,45 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0, 0, 0, 0.03)', backgroundColor: 'rgba(0, 0, 0, 0.03)',
}, },
replyLine: { replyLine: {
width: 0, width: 3,
marginRight: 0, alignSelf: 'stretch',
minHeight: 28,
borderRadius: 2,
backgroundColor: colors.chat.link,
}, },
replyContent: { replyContent: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
flexWrap: 'nowrap', flexWrap: 'nowrap',
minWidth: 0,
}, },
replySender: { replySender: {
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#4A88C7', color: colors.chat.link,
marginRight: 6, marginRight: 6,
flexShrink: 0, flexShrink: 0,
}, },
replyText: { replyText: {
fontSize: 13, fontSize: 13,
color: '#666666', color: colors.chat.textTertiary,
flex: 1, flex: 1,
flexShrink: 1, flexShrink: 1,
}, },
}); });
}
type SegmentStyles = ReturnType<typeof createSegmentStyles>;
const SegmentStylesContext = React.createContext<SegmentStyles | null>(null);
function useSegmentStyles(): SegmentStyles {
const ctx = React.useContext(SegmentStylesContext);
if (!ctx) {
throw new Error('useSegmentStyles 必须在 MessageSegmentsRenderer 内使用');
}
return ctx;
}
export default MessageSegmentsRenderer; export default MessageSegmentsRenderer;

View File

@@ -8,7 +8,7 @@ import { View, StyleSheet, Animated } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PanGestureHandler, State } from 'react-native-gesture-handler'; import { PanGestureHandler, State } from 'react-native-gesture-handler';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { colors } from '../../../../theme'; import { useAppColors } from '../../../../theme';
// 滑动阈值 // 滑动阈值
const SWIPE_THRESHOLD = 30; const SWIPE_THRESHOLD = 30;
@@ -27,6 +27,7 @@ export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
onReply, onReply,
enabled = true, enabled = true,
}) => { }) => {
const colors = useAppColors();
const translateX = useRef(new Animated.Value(0)).current; const translateX = useRef(new Animated.Value(0)).current;
const hasTriggeredRef = useRef(false); const hasTriggeredRef = useRef(false);

View File

@@ -4,52 +4,40 @@
*/ */
import { StyleSheet, ViewStyle, TextStyle } from 'react-native'; import { StyleSheet, ViewStyle, TextStyle } from 'react-native';
import { colors, spacing } from '../../../../theme'; import { spacing, type AppColors } from '../../../../theme';
// 气泡圆角大小(参考 Element X: 12pt
export const BUBBLE_RADIUS = 16; export const BUBBLE_RADIUS = 16;
// 气泡颜色方案(参考 Element X 的语义化颜色) export function getBubbleColors(colors: AppColors) {
export const bubbleColors = { return {
// 自己发送的消息 outgoing: {
outgoing: { background: colors.chat.bubbleOutgoing,
background: '#E8E8E8', // Element X 风格:灰色系 text: colors.chat.textPrimary,
text: '#1A1A1A', },
}, incoming: {
// 收到的消息 background: colors.chat.bubbleIncoming,
incoming: { text: colors.chat.textPrimary,
background: '#FFFFFF', },
text: '#1A1A1A', replyHighlight: {
}, background: colors.chat.replyTint,
// 被回复的消息高亮 borderLeft: colors.chat.replyBorder,
replyHighlight: { },
background: 'rgba(74, 136, 199, 0.1)', };
borderLeft: '#4A88C7', }
},
};
// 消息在组中的位置类型
export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last'; export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last';
/** export const getBubbleBorderRadius = (isMe: boolean, position: MessageGroupPosition): ViewStyle => {
* 根据消息在组中的位置获取圆角样式
* 参考 Element X 的动态圆角逻辑
*/
export const getBubbleBorderRadius = (
isMe: boolean,
position: MessageGroupPosition
): ViewStyle => {
const radius = BUBBLE_RADIUS; const radius = BUBBLE_RADIUS;
if (isMe) { if (isMe) {
// 自己发送的消息
switch (position) { switch (position) {
case 'single': case 'single':
return { return {
borderTopLeftRadius: radius, borderTopLeftRadius: radius,
borderTopRightRadius: radius, borderTopRightRadius: radius,
borderBottomLeftRadius: radius, borderBottomLeftRadius: radius,
borderBottomRightRadius: 4, // 尖角在右下 borderBottomRightRadius: 4,
}; };
case 'first': case 'first':
return { return {
@@ -74,11 +62,10 @@ export const getBubbleBorderRadius = (
}; };
} }
} else { } else {
// 收到的消息
switch (position) { switch (position) {
case 'single': case 'single':
return { return {
borderTopLeftRadius: 4, // 尖角在左上 borderTopLeftRadius: 4,
borderTopRightRadius: radius, borderTopRightRadius: radius,
borderBottomLeftRadius: radius, borderBottomLeftRadius: radius,
borderBottomRightRadius: radius, borderBottomRightRadius: radius,
@@ -108,9 +95,6 @@ export const getBubbleBorderRadius = (
} }
}; };
/**
* 判断消息在组中的位置
*/
export const getMessageGroupPosition = ( export const getMessageGroupPosition = (
index: number, index: number,
messages: Array<{ sender_id: string }>, messages: Array<{ sender_id: string }>,
@@ -118,13 +102,13 @@ export const getMessageGroupPosition = (
): MessageGroupPosition => { ): MessageGroupPosition => {
const currentMessage = messages[index]; const currentMessage = messages[index];
if (!currentMessage) return 'single'; if (!currentMessage) return 'single';
const prevMessage = index > 0 ? messages[index - 1] : null; const prevMessage = index > 0 ? messages[index - 1] : null;
const nextMessage = index < messages.length - 1 ? messages[index + 1] : null; const nextMessage = index < messages.length - 1 ? messages[index + 1] : null;
const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id; const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id;
const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id; const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id;
if (!isSameSenderAsPrev && !isSameSenderAsNext) { if (!isSameSenderAsPrev && !isSameSenderAsNext) {
return 'single'; return 'single';
} else if (!isSameSenderAsPrev && isSameSenderAsNext) { } else if (!isSameSenderAsPrev && isSameSenderAsNext) {
@@ -136,135 +120,114 @@ export const getMessageGroupPosition = (
} }
}; };
/**
* 判断是否显示发送者信息(只在组的第一条消息显示)
*/
export const shouldShowSenderInfo = ( export const shouldShowSenderInfo = (
index: number, index: number,
messages: Array<{ sender_id: string }>, messages: Array<{ sender_id: string }>
): boolean => { ): boolean => {
if (index === 0) return true; if (index === 0) return true;
const currentMessage = messages[index]; const currentMessage = messages[index];
const prevMessage = messages[index - 1]; const prevMessage = messages[index - 1];
return prevMessage.sender_id !== currentMessage.sender_id; return prevMessage.sender_id !== currentMessage.sender_id;
}; };
/** export const getBubbleBaseStyle = (isMe: boolean, colors: AppColors): ViewStyle => {
* 获取气泡基础样式 const bc = getBubbleColors(colors);
*/ return {
export const getBubbleBaseStyle = (isMe: boolean): ViewStyle => ({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4,
minWidth: 60,
maxWidth: '75%',
backgroundColor: isMe ? bubbleColors.outgoing.background : bubbleColors.incoming.background,
// Element X 风格的微妙阴影
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 2,
});
/**
* 获取文本样式
*/
export const getBubbleTextStyle = (isMe: boolean): TextStyle => ({
color: isMe ? bubbleColors.outgoing.text : bubbleColors.incoming.text,
fontSize: 16,
lineHeight: 23,
letterSpacing: 0.2,
fontWeight: '400',
});
/**
* 消息气泡样式集合
*/
export const bubbleStyles = StyleSheet.create({
// 基础气泡
bubble: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4, paddingVertical: spacing.sm + 4,
minWidth: 60, minWidth: 60,
}, maxWidth: '75%',
backgroundColor: isMe ? bc.outgoing.background : bc.incoming.background,
// 自己发送的消息 shadowColor: colors.chat.shadow,
outgoing: {
backgroundColor: bubbleColors.outgoing.background,
},
// 收到的消息
incoming: {
backgroundColor: bubbleColors.incoming.background,
},
// 文本样式
text: {
fontSize: 16,
lineHeight: 23,
letterSpacing: 0.2,
fontWeight: '400',
},
// 阴影样式Element X 风格)
shadow: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06, shadowOpacity: 0.06,
shadowRadius: 2, shadowRadius: 2,
elevation: 2, elevation: 2,
}, };
};
// 长按反馈阴影
longPressShadow: { export const getBubbleTextStyle = (isMe: boolean, colors: AppColors): TextStyle => {
shadowColor: '#000', const bc = getBubbleColors(colors);
shadowOffset: { width: 0, height: 3 }, return {
shadowOpacity: 0.12, color: isMe ? bc.outgoing.text : bc.incoming.text,
shadowRadius: 8, fontSize: 16,
elevation: 6, lineHeight: 23,
}, letterSpacing: 0.2,
fontWeight: '400',
// 被回复消息的高亮边框 };
replyHighlight: { };
borderLeftWidth: 3,
borderLeftColor: colors.primary.main, export function createBubbleStyles(colors: AppColors) {
backgroundColor: bubbleColors.replyHighlight.background, const bc = getBubbleColors(colors);
}, return StyleSheet.create({
bubble: {
// 已撤回消息 paddingHorizontal: spacing.md,
recalled: { paddingVertical: spacing.sm + 4,
backgroundColor: '#F5F7FA', minWidth: 60,
borderWidth: 1, },
borderColor: '#E8E8E8', outgoing: {
borderStyle: 'dashed', backgroundColor: bc.outgoing.background,
borderRadius: 12, },
}, incoming: {
backgroundColor: bc.incoming.background,
// 系统通知 },
systemNotice: { text: {
alignItems: 'center', fontSize: 16,
marginVertical: spacing.sm, lineHeight: 23,
}, letterSpacing: 0.2,
systemNoticeText: { fontWeight: '400',
fontSize: 12, },
color: '#8E8E93', shadow: {
backgroundColor: 'rgba(142, 142, 147, 0.12)', shadowColor: colors.chat.shadow,
paddingHorizontal: spacing.md, shadowOffset: { width: 0, height: 1 },
paddingVertical: spacing.xs, shadowOpacity: 0.06,
borderRadius: 12, shadowRadius: 2,
overflow: 'hidden', elevation: 2,
}, },
}); longPressShadow: {
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 8,
elevation: 6,
},
replyHighlight: {
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
backgroundColor: bc.replyHighlight.background,
},
recalled: {
backgroundColor: colors.chat.surfaceMuted,
borderWidth: 1,
borderColor: colors.chat.border,
borderStyle: 'dashed',
borderRadius: 12,
},
systemNotice: {
alignItems: 'center',
marginVertical: spacing.sm,
},
systemNoticeText: {
fontSize: 12,
color: colors.chat.textSecondary,
backgroundColor: colors.chat.surfaceMuted,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 12,
overflow: 'hidden',
},
});
}
export default { export default {
BUBBLE_RADIUS, BUBBLE_RADIUS,
bubbleColors, getBubbleColors,
getBubbleBorderRadius, getBubbleBorderRadius,
getMessageGroupPosition, getMessageGroupPosition,
shouldShowSenderInfo, shouldShowSenderInfo,
getBubbleBaseStyle, getBubbleBaseStyle,
getBubbleTextStyle, getBubbleTextStyle,
bubbleStyles, createBubbleStyles,
}; };

View File

@@ -9,7 +9,7 @@ export * from './types';
export * from './constants'; export * from './constants';
// 样式 // 样式
export { chatScreenStyles } from './styles'; export { createChatScreenStyles, useChatScreenStyles } from './styles';
// 组件 // 组件
export { EmojiPanel } from './EmojiPanel'; export { EmojiPanel } from './EmojiPanel';

View File

@@ -3,8 +3,9 @@
* 支持响应式布局 * 支持响应式布局
*/ */
import { useMemo } from 'react';
import { StyleSheet, Dimensions } from 'react-native'; import { StyleSheet, Dimensions } from 'react-native';
import { colors, spacing, shadows } from '../../../../theme'; import { spacing, shadows, useAppColors, type AppColors } from '../../../../theme';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -18,19 +19,20 @@ const BREAKPOINTS = {
const isWideScreen = SCREEN_WIDTH >= BREAKPOINTS.desktop; const isWideScreen = SCREEN_WIDTH >= BREAKPOINTS.desktop;
const isTablet = SCREEN_WIDTH >= BREAKPOINTS.tablet; const isTablet = SCREEN_WIDTH >= BREAKPOINTS.tablet;
export const chatScreenStyles = StyleSheet.create({ export function createChatScreenStyles(colors: AppColors) {
return StyleSheet.create({
// 容器 // 容器
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#F0F2F5', backgroundColor: colors.chat.screen,
}, },
// 头部 // 头部
headerContainer: { headerContainer: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 107, 53, 0.12)', borderBottomColor: 'rgba(255, 107, 53, 0.12)',
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 3 }, shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.06, shadowOpacity: 0.06,
shadowRadius: 10, shadowRadius: 10,
@@ -42,7 +44,7 @@ export const chatScreenStyles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2, paddingVertical: spacing.sm + 2,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
}, },
backButton: { backButton: {
width: 38, width: 38,
@@ -112,36 +114,61 @@ export const chatScreenStyles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: 19, borderRadius: 19,
backgroundColor: '#F7F8FA', backgroundColor: colors.chat.surfaceRaised,
borderWidth: 1, borderWidth: 1,
borderColor: '#ECEFF3', borderColor: colors.chat.borderLight,
}, },
// 消息列表 // 消息列表
messageListContainer: { messageListContainer: {
flex: 1, flex: 1,
}, },
// 回到底部Telegram 式浮动条)
jumpToLatestFab: {
position: 'absolute',
right: 14,
bottom: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(0, 0, 0, 0.08)',
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 6,
elevation: 4,
},
jumpToLatestFabText: {
fontSize: 13,
fontWeight: '600',
color: colors.primary.main,
},
listContent: { listContent: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingTop: spacing.md, paddingTop: spacing.md,
paddingBottom: spacing.xl, paddingBottom: spacing.xl,
}, },
// 时间分隔 - QQ风格更明显的时间标签 // 时间分隔 - 轻量胶囊(弱化字重,接近 Telegram 日期条)
timeContainer: { timeContainer: {
alignItems: 'center', alignItems: 'center',
marginVertical: spacing.lg, marginVertical: spacing.lg,
}, },
timeText: { timeText: {
color: '#8E8E93', color: 'rgba(142, 142, 147, 0.95)',
fontSize: 12, fontSize: 11,
backgroundColor: 'rgba(142, 142, 147, 0.12)', backgroundColor: 'rgba(142, 142, 147, 0.1)',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.sm + 4,
paddingVertical: 6, paddingVertical: 5,
borderRadius: 14, borderRadius: 12,
fontWeight: '600', fontWeight: '500',
overflow: 'hidden', overflow: 'hidden',
letterSpacing: 0.3, letterSpacing: 0.2,
}, },
// 系统通知 // 系统通知
@@ -152,7 +179,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
systemNoticeText: { systemNoticeText: {
fontSize: 12, fontSize: 12,
color: '#8E8E93', color: colors.chat.textSecondary,
backgroundColor: 'rgba(142, 142, 147, 0.12)', backgroundColor: 'rgba(142, 142, 147, 0.12)',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
@@ -177,7 +204,7 @@ export const chatScreenStyles = StyleSheet.create({
// 头像 // 头像
avatarWrapper: { avatarWrapper: {
marginHorizontal: 2, marginHorizontal: 2,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 2, shadowRadius: 2,
@@ -185,7 +212,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
groupAvatarWrapper: { groupAvatarWrapper: {
marginHorizontal: 2, marginHorizontal: 2,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 2, shadowRadius: 2,
@@ -200,53 +227,59 @@ export const chatScreenStyles = StyleSheet.create({
}, },
senderName: { senderName: {
fontSize: 13, fontSize: 13,
color: '#4A88C7', color: colors.chat.link,
marginBottom: 4, marginBottom: 4,
marginLeft: 4, marginLeft: 4,
fontWeight: '600', fontWeight: '600',
}, },
// 消息气泡 - Telegram风格浅色背景统一字体颜色 // 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM
messageBubble: { messageBubbleOuter: {
borderRadius: 16,
minWidth: 60,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 0.5 },
shadowOpacity: 0.04,
shadowRadius: 1,
elevation: 1,
},
myBubbleOuter: {
borderBottomRightRadius: 4,
},
theirBubbleOuter: {
borderTopLeftRadius: 4,
},
// 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色
messageBubbleInner: {
borderRadius: 16,
overflow: 'hidden',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4, paddingVertical: spacing.sm + 4,
borderRadius: 16,
// 自适应内容宽度
minWidth: 60, minWidth: 60,
}, },
myBubble: { myBubbleInner: {
backgroundColor: '#DFF2FF', // Telegram风格的浅蓝色 backgroundColor: colors.chat.replyTint,
borderBottomRightRadius: 4, // 右下角尖,箭头在右下 borderBottomRightRadius: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 2,
}, },
theirBubble: { theirBubbleInner: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderTopLeftRadius: 4, // 左上角尖,箭头在左上 borderTopLeftRadius: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 2,
}, },
recalledBubble: { recalledBubble: {
backgroundColor: '#F5F7FA', backgroundColor: colors.chat.surfaceMuted,
borderWidth: 1, borderWidth: 1,
borderColor: '#E8E8E8', borderColor: colors.chat.border,
borderStyle: 'dashed', borderStyle: 'dashed',
}, },
myBubbleText: { myBubbleText: {
color: '#1A1A1A', // 统一使用深色字体 color: colors.chat.textPrimary, // 主文案
fontSize: 16, fontSize: 16,
lineHeight: 23, lineHeight: 23,
letterSpacing: 0.2, letterSpacing: 0.2,
fontWeight: '400', fontWeight: '400',
}, },
theirBubbleText: { theirBubbleText: {
color: '#1A1A1A', // 统一使用深色字体 color: colors.chat.textPrimary, // 主文案
fontSize: 16, fontSize: 16,
lineHeight: 23, lineHeight: 23,
letterSpacing: 0.2, letterSpacing: 0.2,
@@ -255,29 +288,27 @@ export const chatScreenStyles = StyleSheet.create({
// 选中状态 // 选中状态
selectedBubble: { selectedBubble: {
borderWidth: 2, borderWidth: 2,
borderColor: '#7FB6E6', borderColor: colors.chat.replyBorder,
}, },
// 仅对齐右侧,不再套一层浅色底,避免与气泡叠色产生「边缘多出一块蓝」
myMessageContentPanel: { myMessageContentPanel: {
backgroundColor: '#DFF2FF', alignItems: 'flex-end',
borderRadius: 14,
paddingHorizontal: 4,
paddingBottom: 4,
}, },
mySelectedBubble: { mySelectedBubble: {
backgroundColor: '#CFEAFF', backgroundColor: colors.chat.replyTintActive,
borderWidth: 2, borderWidth: 2,
borderColor: '#7FB6E6', borderColor: colors.chat.replyBorder,
}, },
theirSelectedBubble: { theirSelectedBubble: {
backgroundColor: '#EEF5FC', backgroundColor: colors.chat.menuHighlight,
borderWidth: 2, borderWidth: 2,
borderColor: '#7FB6E6', borderColor: colors.chat.replyBorder,
}, },
selectedText: { selectedText: {
// 文本选中时的样式 // 文本选中时的样式
}, },
recalledText: { recalledText: {
color: '#8E8E93', color: colors.chat.textSecondary,
fontStyle: 'italic', fontStyle: 'italic',
}, },
@@ -285,7 +316,7 @@ export const chatScreenStyles = StyleSheet.create({
imageBubble: { imageBubble: {
borderRadius: 18, borderRadius: 18,
overflow: 'hidden', overflow: 'hidden',
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 3 }, shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.15, shadowOpacity: 0.15,
shadowRadius: 6, shadowRadius: 6,
@@ -314,27 +345,28 @@ export const chatScreenStyles = StyleSheet.create({
}, },
readStatusText: { readStatusText: {
fontSize: 10, fontSize: 10,
color: '#8E8E93', color: colors.chat.textSecondary,
fontWeight: '500', fontWeight: '500',
}, },
readStatusTextRead: { readStatusTextRead: {
color: '#34C759', color: colors.chat.success,
}, },
// 输入框区域 // 输入框区域 - 与列表背景同色底栏,顶部细分隔
inputWrapper: { inputWrapper: {
backgroundColor: 'transparent', backgroundColor: colors.chat.screen,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(0, 0, 0, 0.06)',
}, },
inputContainer: { inputContainer: {
backgroundColor: colors.background.paper, backgroundColor: 'transparent',
borderWidth: 1, borderWidth: 0,
borderColor: colors.divider, borderRadius: 0,
borderRadius: 24, marginHorizontal: 10,
marginHorizontal: 14, marginBottom: 10,
marginBottom: 12, marginTop: 2,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
// 移除明显的阴影效果,改用更微妙的边框
shadowColor: 'transparent', shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0, shadowOpacity: 0,
@@ -344,7 +376,7 @@ export const chatScreenStyles = StyleSheet.create({
inputInner: { inputInner: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#F7F8FA', backgroundColor: colors.chat.surfaceRaised,
borderRadius: 20, borderRadius: 20,
paddingHorizontal: spacing.xs, paddingHorizontal: spacing.xs,
paddingVertical: 4, paddingVertical: 4,
@@ -357,7 +389,7 @@ export const chatScreenStyles = StyleSheet.create({
elevation: 0, elevation: 0,
}, },
inputInnerMuted: { inputInnerMuted: {
backgroundColor: '#F0F0F0', backgroundColor: colors.chat.borderHairline,
opacity: 0.7, opacity: 0.7,
}, },
@@ -366,7 +398,7 @@ export const chatScreenStyles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#FFF5F5', backgroundColor: colors.chat.warningBg,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
marginBottom: spacing.xs, marginBottom: spacing.xs,
@@ -375,7 +407,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
mutedBannerText: { mutedBannerText: {
fontSize: 13, fontSize: 13,
color: '#FF3B30', color: colors.chat.danger,
fontWeight: '500', fontWeight: '500',
}, },
@@ -421,14 +453,14 @@ export const chatScreenStyles = StyleSheet.create({
}, },
input: { input: {
fontSize: 16, fontSize: 16,
color: '#1A1A1A', color: colors.chat.textPrimary,
paddingTop: 8, paddingTop: 8,
paddingBottom: 8, paddingBottom: 8,
maxHeight: 100, maxHeight: 100,
lineHeight: 20, lineHeight: 20,
}, },
inputMuted: { inputMuted: {
color: '#CCC', color: colors.chat.iconMuted,
}, },
inputTransparent: { inputTransparent: {
color: 'transparent', color: 'transparent',
@@ -444,7 +476,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
inputHighlightText: { inputHighlightText: {
fontSize: 16, fontSize: 16,
color: '#1A1A1A', color: colors.chat.textPrimary,
lineHeight: 20, lineHeight: 20,
}, },
inputHighlightMention: { inputHighlightMention: {
@@ -462,10 +494,10 @@ export const chatScreenStyles = StyleSheet.create({
// 面板 // 面板
panelWrapper: { panelWrapper: {
backgroundColor: '#F5F7FA', backgroundColor: colors.chat.surfaceMuted,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#E8E8E8', borderTopColor: colors.chat.border,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: -2 }, shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 8, shadowRadius: 8,
@@ -473,11 +505,11 @@ export const chatScreenStyles = StyleSheet.create({
}, },
// 表情面板包装器 - 底部 tab 栏是白色,安全区域也用白色填充 // 表情面板包装器 - 底部 tab 栏是白色,安全区域也用白色填充
emojiPanelWrapper: { emojiPanelWrapper: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
}, },
panelContainer: { panelContainer: {
flex: 1, flex: 1,
backgroundColor: '#F5F7FA', backgroundColor: colors.chat.surfaceMuted,
}, },
// 表情面板 // 表情面板
@@ -505,16 +537,16 @@ export const chatScreenStyles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#E8E8E8', borderTopColor: colors.chat.border,
}, },
panelCloseButton: { panelCloseButton: {
width: 40, width: 40,
height: 40, height: 40,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderRadius: 8, borderRadius: 8,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 2, shadowRadius: 2,
@@ -533,8 +565,8 @@ export const chatScreenStyles = StyleSheet.create({
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#E8E8E8', borderTopColor: colors.chat.border,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
}, },
panelTab: { panelTab: {
width: 44, width: 44,
@@ -545,7 +577,7 @@ export const chatScreenStyles = StyleSheet.create({
marginRight: spacing.xs, marginRight: spacing.xs,
}, },
panelTabActive: { panelTabActive: {
backgroundColor: '#DFF2FF', backgroundColor: colors.chat.replyTint,
}, },
panelTabEmoji: { panelTabEmoji: {
fontSize: 24, fontSize: 24,
@@ -553,7 +585,7 @@ export const chatScreenStyles = StyleSheet.create({
panelTabDivider: { panelTabDivider: {
width: 1, width: 1,
height: 24, height: 24,
backgroundColor: '#E8E8E8', backgroundColor: colors.chat.bubbleOutgoing,
marginHorizontal: spacing.sm, marginHorizontal: spacing.sm,
}, },
@@ -571,13 +603,13 @@ export const chatScreenStyles = StyleSheet.create({
}, },
stickerEmptyText: { stickerEmptyText: {
fontSize: 16, fontSize: 16,
color: '#666', color: colors.chat.textTertiary,
marginTop: spacing.md, marginTop: spacing.md,
fontWeight: '500', fontWeight: '500',
}, },
stickerEmptySubText: { stickerEmptySubText: {
fontSize: 13, fontSize: 13,
color: '#999', color: colors.chat.textMuted,
marginTop: spacing.xs, marginTop: spacing.xs,
}, },
stickerGrid: { stickerGrid: {
@@ -593,7 +625,7 @@ export const chatScreenStyles = StyleSheet.create({
margin: 4, margin: 4,
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: '#F5F5F5', backgroundColor: colors.chat.surfaceInput,
}, },
stickerImage: { stickerImage: {
width: '100%', width: '100%',
@@ -618,8 +650,8 @@ export const chatScreenStyles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: spacing.sm, marginBottom: spacing.sm,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 4, shadowRadius: 4,
@@ -627,7 +659,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
moreItemText: { moreItemText: {
fontSize: 13, fontSize: 13,
color: '#666', color: colors.chat.textTertiary,
fontWeight: '500', fontWeight: '500',
}, },
@@ -636,7 +668,7 @@ export const chatScreenStyles = StyleSheet.create({
position: 'absolute', position: 'absolute',
left: 12, left: 12,
right: 12, right: 12,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderRadius: 18, borderRadius: 18,
shadowColor: colors.primary.main, shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: -3 }, shadowOffset: { width: 0, height: -3 },
@@ -651,7 +683,7 @@ export const chatScreenStyles = StyleSheet.create({
// @成员选择面板 // @成员选择面板
mentionPanelContainer: { mentionPanelContainer: {
flex: 1, flex: 1,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderRadius: 18, borderRadius: 18,
}, },
mentionPanelDragHandle: { mentionPanelDragHandle: {
@@ -663,7 +695,7 @@ export const chatScreenStyles = StyleSheet.create({
width: 32, width: 32,
height: 3, height: 3,
borderRadius: 2, borderRadius: 2,
backgroundColor: '#E0E0E0', backgroundColor: colors.chat.sheetGrip,
}, },
mentionPanelHeader: { mentionPanelHeader: {
flexDirection: 'row', flexDirection: 'row',
@@ -673,12 +705,12 @@ export const chatScreenStyles = StyleSheet.create({
paddingTop: 4, paddingTop: 4,
paddingBottom: 8, paddingBottom: 8,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#F0F0F0', borderBottomColor: colors.chat.borderHairline,
}, },
mentionPanelTitle: { mentionPanelTitle: {
fontSize: 12, fontSize: 12,
fontWeight: '600', fontWeight: '600',
color: '#AAAAAA', color: colors.chat.textPlaceholder,
letterSpacing: 0.5, letterSpacing: 0.5,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
@@ -686,7 +718,7 @@ export const chatScreenStyles = StyleSheet.create({
width: 26, width: 26,
height: 26, height: 26,
borderRadius: 13, borderRadius: 13,
backgroundColor: '#F5F5F5', backgroundColor: colors.chat.surfaceInput,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
@@ -728,7 +760,7 @@ export const chatScreenStyles = StyleSheet.create({
mentionItemName: { mentionItemName: {
fontSize: 15, fontSize: 15,
fontWeight: '500', fontWeight: '500',
color: '#1A1A1A', color: colors.chat.textPrimary,
}, },
mentionItemNameAll: { mentionItemNameAll: {
fontSize: 15, fontSize: 15,
@@ -737,7 +769,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
mentionItemSub: { mentionItemSub: {
fontSize: 12, fontSize: 12,
color: '#BBBBBB', color: colors.chat.iconMuted,
marginTop: 1, marginTop: 1,
}, },
mentionItemRole: { mentionItemRole: {
@@ -752,7 +784,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
mentionEmptyText: { mentionEmptyText: {
fontSize: 14, fontSize: 14,
color: '#C8C8C8', color: colors.chat.iconSoft,
}, },
// 长按菜单 - 底部横条形式类似QQ // 长按菜单 - 底部横条形式类似QQ
@@ -763,10 +795,10 @@ export const chatScreenStyles = StyleSheet.create({
}, },
// 旧版菜单样式(保留兼容) // 旧版菜单样式(保留兼容)
menuContainer: { menuContainer: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderRadius: 12, borderRadius: 12,
minWidth: 160, minWidth: 160,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15, shadowOpacity: 0.15,
shadowRadius: 12, shadowRadius: 12,
@@ -781,21 +813,21 @@ export const chatScreenStyles = StyleSheet.create({
}, },
menuItemBorder: { menuItemBorder: {
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#F0F0F0', borderBottomColor: colors.chat.borderHairline,
}, },
menuItemText: { menuItemText: {
fontSize: 15, fontSize: 15,
color: '#333', color: colors.text.primary,
fontWeight: '500', fontWeight: '500',
}, },
// 底部菜单容器 // 底部菜单容器
bottomMenuContainer: { bottomMenuContainer: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderTopLeftRadius: 20, borderTopLeftRadius: 20,
borderTopRightRadius: 20, borderTopRightRadius: 20,
paddingTop: spacing.md, paddingTop: spacing.md,
paddingBottom: spacing.xl, paddingBottom: spacing.xl,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: -4 }, shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 8, shadowRadius: 8,
@@ -803,7 +835,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
// 消息预览区域 // 消息预览区域
messagePreviewContainer: { messagePreviewContainer: {
backgroundColor: '#F5F7FA', backgroundColor: colors.chat.surfaceMuted,
marginHorizontal: spacing.md, marginHorizontal: spacing.md,
marginBottom: spacing.md, marginBottom: spacing.md,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
@@ -813,7 +845,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
messagePreviewText: { messagePreviewText: {
fontSize: 14, fontSize: 14,
color: '#666', color: colors.chat.textTertiary,
lineHeight: 20, lineHeight: 20,
}, },
// 操作按钮横条 // 操作按钮横条
@@ -824,7 +856,7 @@ export const chatScreenStyles = StyleSheet.create({
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#F0F0F0', borderBottomColor: colors.chat.borderHairline,
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
menuActionItem: { menuActionItem: {
@@ -839,19 +871,19 @@ export const chatScreenStyles = StyleSheet.create({
width: 56, width: 56,
height: 56, height: 56,
borderRadius: 16, borderRadius: 16,
backgroundColor: '#F0F7FF', backgroundColor: colors.chat.overlayQuote,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 8, marginBottom: 8,
}, },
menuActionLabel: { menuActionLabel: {
fontSize: 13, fontSize: 13,
color: '#333', color: colors.text.primary,
fontWeight: '500', fontWeight: '500',
}, },
// 取消按钮 // 取消按钮
menuCancelButton: { menuCancelButton: {
backgroundColor: '#F5F7FA', backgroundColor: colors.chat.surfaceMuted,
marginHorizontal: spacing.md, marginHorizontal: spacing.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: 12, borderRadius: 12,
@@ -859,7 +891,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
menuCancelText: { menuCancelText: {
fontSize: 16, fontSize: 16,
color: '#333', color: colors.text.primary,
fontWeight: '600', fontWeight: '600',
}, },
@@ -874,7 +906,7 @@ export const chatScreenStyles = StyleSheet.create({
borderRadius: 6, borderRadius: 6,
paddingVertical: 6, paddingVertical: 6,
paddingHorizontal: 2, paddingHorizontal: 2,
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 4, shadowRadius: 4,
@@ -901,20 +933,21 @@ export const chatScreenStyles = StyleSheet.create({
}, },
qqMenuItemLabel: { qqMenuItemLabel: {
fontSize: 11, fontSize: 11,
color: '#FFFFFF', color: colors.primary.contrast,
fontWeight: '500', fontWeight: '500',
}, },
// 回复预览 // 回复预览(输入区)- 与气泡内引用同色条
replyPreviewContainer: { replyPreviewContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#F5F7FA', backgroundColor: 'rgba(74, 136, 199, 0.08)',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderLeftWidth: 3, borderLeftWidth: 3,
borderLeftColor: colors.primary.main, borderLeftColor: colors.chat.link,
marginBottom: spacing.xs, marginBottom: spacing.xs,
borderRadius: 8,
}, },
replyPreviewContent: { replyPreviewContent: {
flex: 1, flex: 1,
@@ -932,7 +965,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
replyPreviewMessage: { replyPreviewMessage: {
fontSize: 12, fontSize: 12,
color: '#8E8E93', color: colors.chat.textSecondary,
marginTop: 2, marginTop: 2,
}, },
replyPreviewClose: { replyPreviewClose: {
@@ -948,11 +981,11 @@ export const chatScreenStyles = StyleSheet.create({
zIndex: 1000, zIndex: 1000,
}, },
overlayContent: { overlayContent: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
padding: spacing.xl, padding: spacing.xl,
borderRadius: 16, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
shadowColor: '#000', shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15, shadowOpacity: 0.15,
shadowRadius: 12, shadowRadius: 12,
@@ -961,13 +994,13 @@ export const chatScreenStyles = StyleSheet.create({
overlayText: { overlayText: {
marginTop: spacing.md, marginTop: spacing.md,
fontSize: 15, fontSize: 15,
color: '#666', color: colors.chat.textTertiary,
fontWeight: '500', fontWeight: '500',
}, },
// 表情包添加按钮(管理界面第一位) // 表情包添加按钮(管理界面第一位)
stickerAddButton: { stickerAddButton: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderWidth: 1.5, borderWidth: 1.5,
borderColor: colors.primary.main, borderColor: colors.primary.main,
borderStyle: 'dashed', borderStyle: 'dashed',
@@ -986,9 +1019,9 @@ export const chatScreenStyles = StyleSheet.create({
// 表情包管理按钮(表情面板第一位) // 表情包管理按钮(表情面板第一位)
stickerManageButton: { stickerManageButton: {
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderWidth: 1, borderWidth: 1,
borderColor: '#E8E8E8', borderColor: colors.chat.border,
borderStyle: 'dashed', borderStyle: 'dashed',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -1000,7 +1033,7 @@ export const chatScreenStyles = StyleSheet.create({
}, },
stickerManageText: { stickerManageText: {
fontSize: 12, fontSize: 12,
color: '#666', color: colors.chat.textTertiary,
marginTop: 4, marginTop: 4,
fontWeight: '500', fontWeight: '500',
}, },
@@ -1018,20 +1051,20 @@ export const chatScreenStyles = StyleSheet.create({
width: 36, width: 36,
height: 5, height: 5,
borderRadius: 3, borderRadius: 3,
backgroundColor: '#E0E0E0', backgroundColor: colors.chat.sheetGrip,
alignSelf: 'center', alignSelf: 'center',
marginTop: 8, marginTop: 8,
marginBottom: 4, marginBottom: 4,
}, },
manageHeaderDivider: { manageHeaderDivider: {
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,
backgroundColor: '#F0F0F0', backgroundColor: colors.chat.borderHairline,
}, },
// 表情包选择状态 // 表情包选择状态
stickerItemSelected: { stickerItemSelected: {
borderWidth: 2, borderWidth: 2,
borderColor: '#7FB6E6', borderColor: colors.chat.replyBorder,
}, },
stickerCheckOverlay: { stickerCheckOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -1045,19 +1078,19 @@ export const chatScreenStyles = StyleSheet.create({
height: 22, height: 22,
borderRadius: 11, borderRadius: 11,
borderWidth: 2, borderWidth: 2,
borderColor: '#FFF', borderColor: colors.chat.card,
backgroundColor: 'rgba(255, 255, 255, 0.8)', backgroundColor: 'rgba(255, 255, 255, 0.8)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
stickerCheckBoxSelected: { stickerCheckBoxSelected: {
backgroundColor: '#7FB6E6', backgroundColor: colors.chat.replyBorder,
borderColor: '#7FB6E6', borderColor: colors.chat.replyBorder,
}, },
// 表情管理模态框 // 表情管理模态框
manageModalContainer: { manageModalContainer: {
backgroundColor: '#F5F7FA', backgroundColor: colors.chat.surfaceMuted,
borderTopLeftRadius: 20, borderTopLeftRadius: 20,
borderTopRightRadius: 20, borderTopRightRadius: 20,
overflow: 'hidden', overflow: 'hidden',
@@ -1068,9 +1101,9 @@ export const chatScreenStyles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#E8E8E8', borderBottomColor: colors.chat.border,
}, },
manageHeaderButton: { manageHeaderButton: {
minWidth: 60, minWidth: 60,
@@ -1079,28 +1112,28 @@ export const chatScreenStyles = StyleSheet.create({
manageModalTitle: { manageModalTitle: {
fontSize: 17, fontSize: 17,
fontWeight: '600', fontWeight: '600',
color: '#1A1A1A', color: colors.chat.textPrimary,
}, },
manageDoneText: { manageDoneText: {
fontSize: 16, fontSize: 16,
color: '#4A88C7', color: colors.chat.link,
fontWeight: '500', fontWeight: '500',
}, },
manageSelectText: { manageSelectText: {
fontSize: 16, fontSize: 16,
color: '#4A88C7', color: colors.chat.link,
fontWeight: '500', fontWeight: '500',
}, },
manageInfoBar: { manageInfoBar: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#E8E8E8', borderBottomColor: colors.chat.border,
}, },
manageInfoText: { manageInfoText: {
fontSize: 13, fontSize: 13,
color: '#8E8E93', color: colors.chat.textSecondary,
}, },
manageStickerGrid: { manageStickerGrid: {
flexDirection: 'row', flexDirection: 'row',
@@ -1115,9 +1148,9 @@ export const chatScreenStyles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
backgroundColor: '#FFFFFF', backgroundColor: colors.chat.card,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#E8E8E8', borderTopColor: colors.chat.border,
paddingBottom: 34, // 安全区域 paddingBottom: 34, // 安全区域
}, },
manageSelectAllButton: { manageSelectAllButton: {
@@ -1126,24 +1159,24 @@ export const chatScreenStyles = StyleSheet.create({
}, },
manageSelectAllText: { manageSelectAllText: {
fontSize: 15, fontSize: 15,
color: '#4A88C7', color: colors.chat.link,
fontWeight: '500', fontWeight: '500',
}, },
manageDeleteButton: { manageDeleteButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#FF3B30', backgroundColor: colors.chat.danger,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2, paddingVertical: spacing.sm + 2,
borderRadius: 20, borderRadius: 20,
gap: 6, gap: 6,
}, },
manageDeleteButtonDisabled: { manageDeleteButtonDisabled: {
backgroundColor: '#CCC', backgroundColor: colors.chat.iconMuted,
}, },
manageDeleteText: { manageDeleteText: {
fontSize: 15, fontSize: 15,
color: '#FFFFFF', color: colors.primary.contrast,
fontWeight: '600', fontWeight: '600',
}, },
manageTipBar: { manageTipBar: {
@@ -1152,12 +1185,12 @@ export const chatScreenStyles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
backgroundColor: '#FFF9E6', backgroundColor: colors.chat.tipBg,
gap: 6, gap: 6,
}, },
manageTipText: { manageTipText: {
fontSize: 12, fontSize: 12,
color: '#999', color: colors.chat.textMuted,
}, },
// 管理界面空状态 // 管理界面空状态
@@ -1186,5 +1219,9 @@ export const chatScreenStyles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
}); });
}
export default chatScreenStyles; export function useChatScreenStyles() {
const colors = useAppColors();
return useMemo(() => createChatScreenStyles(colors), [colors]);
}

View File

@@ -546,6 +546,13 @@ export const useChatScreen = () => {
isBrowsingHistoryRef.current = false; isBrowsingHistoryRef.current = false;
}, [isHistoryLoadingLocked]); }, [isHistoryLoadingLocked]);
/** 用户点击「回到底部」解除浏览历史锁并滚到最新消息端inverted 下 offset=0 */
const jumpToLatestMessages = useCallback(() => {
suppressAutoFollowRef.current = false;
isBrowsingHistoryRef.current = false;
scrollToLatest(true, true, 'jump-latest-button');
}, [scrollToLatest]);
const setBrowsingHistory = useCallback((browsing: boolean) => { const setBrowsingHistory = useCallback((browsing: boolean) => {
isBrowsingHistoryRef.current = browsing; isBrowsingHistoryRef.current = browsing;
}, []); }, []);
@@ -1396,6 +1403,7 @@ export const useChatScreen = () => {
handleClearConversation, handleClearConversation,
handleMessageListContentSizeChange, handleMessageListContentSizeChange,
handleReachLatestEdge, handleReachLatestEdge,
jumpToLatestMessages,
setBrowsingHistory, setBrowsingHistory,
}; };
}; };

View File

@@ -3,10 +3,10 @@
* onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。 * onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。
*/ */
import React, { memo, useEffect, useRef, useState } from 'react'; import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, shadows } from '../../../theme'; import { spacing, shadows, useAppColors, type AppColors } from '../../../theme';
import { import {
ConversationResponse, ConversationResponse,
extractTextFromSegments, extractTextFromSegments,
@@ -150,6 +150,124 @@ function areConversationRowEqual(
return true; return true;
} }
function createConversationRowStyles(colors: AppColors) {
return StyleSheet.create({
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 14,
backgroundColor: colors.background.paper,
marginHorizontal: spacing.md,
marginTop: spacing.sm,
borderRadius: 12,
...shadows.sm,
},
conversationItemSelected: {
backgroundColor: colors.primary.light + '20',
borderColor: colors.primary.main,
borderWidth: 1,
},
avatarContainer: {
position: 'relative',
},
systemAvatar: {
width: 50,
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary.main,
},
conversationContent: {
flex: 1,
marginLeft: spacing.md,
},
conversationHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
nameRow: {
flexDirection: 'row',
alignItems: 'center',
},
officialBadge: {
backgroundColor: colors.primary.main,
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
marginRight: spacing.xs,
},
officialBadgeText: {
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '600',
},
userName: {
fontWeight: '600',
color: colors.text.primary,
fontSize: 16,
},
groupIcon: {
marginRight: 4,
},
memberCount: {
fontSize: 12,
color: colors.text.secondary,
marginLeft: 2,
},
pinnedIcon: {
marginLeft: 6,
},
groupAvatar: {
width: 50,
height: 50,
},
groupAvatarPlaceholder: {
width: 50,
height: 50,
borderRadius: 12,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
timeText: {
color: colors.text.secondary,
fontSize: 12,
},
messageRow: {
flexDirection: 'row',
alignItems: 'center',
},
messageText: {
flex: 1,
color: colors.text.secondary,
fontSize: 14,
},
unreadMessageText: {
color: colors.text.primary,
fontWeight: '500',
},
unreadBadge: {
minWidth: 20,
height: 20,
borderRadius: 10,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
paddingHorizontal: 6,
},
unreadBadgeText: {
color: colors.primary.contrast,
fontSize: 12,
fontWeight: '600',
},
});
}
const ConversationListRowInner: React.FC<ConversationListRowProps> = ({ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
item, item,
scale, scale,
@@ -158,6 +276,8 @@ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
formatTime, formatTime,
systemChannelId, systemChannelId,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createConversationRowStyles(colors), [colors]);
const isSystemChannel = item.id === systemChannelId; const isSystemChannel = item.id === systemChannelId;
const isGroupChat = !!(item.type === 'group' && item.group); const isGroupChat = !!(item.type === 'group' && item.group);
@@ -196,7 +316,7 @@ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
{isSystemChannel ? ( {isSystemChannel ? (
<View style={styles.systemAvatar}> <View style={styles.systemAvatar}>
<MaterialCommunityIcons name="bell-ring" size={24} color="#FFF" /> <MaterialCommunityIcons name="bell-ring" size={24} color={colors.primary.contrast} />
</View> </View>
) : isGroupChat ? ( ) : isGroupChat ? (
<View style={styles.groupAvatar}> <View style={styles.groupAvatar}>
@@ -204,7 +324,7 @@ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
<Avatar source={displayAvatar} size={50} name={displayName} /> <Avatar source={displayAvatar} size={50} name={displayName} />
) : ( ) : (
<View style={styles.groupAvatarPlaceholder}> <View style={styles.groupAvatarPlaceholder}>
<MaterialCommunityIcons name="account-group" size={28} color="#FFF" /> <MaterialCommunityIcons name="account-group" size={28} color={colors.primary.contrast} />
</View> </View>
)} )}
</View> </View>
@@ -271,119 +391,3 @@ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
ConversationListRowInner.displayName = 'ConversationListRow'; ConversationListRowInner.displayName = 'ConversationListRow';
export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual); export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual);
const styles = StyleSheet.create({
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 14,
backgroundColor: '#FFF',
marginHorizontal: spacing.md,
marginTop: spacing.sm,
borderRadius: 12,
...shadows.sm,
},
conversationItemSelected: {
backgroundColor: colors.primary.light + '20',
borderColor: colors.primary.main,
borderWidth: 1,
},
avatarContainer: {
position: 'relative',
},
systemAvatar: {
width: 50,
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary.main,
},
conversationContent: {
flex: 1,
marginLeft: spacing.md,
},
conversationHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
nameRow: {
flexDirection: 'row',
alignItems: 'center',
},
officialBadge: {
backgroundColor: colors.primary.main,
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
marginRight: spacing.xs,
},
officialBadgeText: {
color: '#FFF',
fontSize: 10,
fontWeight: '600',
},
userName: {
fontWeight: '600',
color: '#333',
fontSize: 16,
},
groupIcon: {
marginRight: 4,
},
memberCount: {
fontSize: 12,
color: '#999',
marginLeft: 2,
},
pinnedIcon: {
marginLeft: 6,
},
groupAvatar: {
width: 50,
height: 50,
},
groupAvatarPlaceholder: {
width: 50,
height: 50,
borderRadius: 12,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
timeText: {
color: '#999',
fontSize: 12,
},
messageRow: {
flexDirection: 'row',
alignItems: 'center',
},
messageText: {
flex: 1,
color: '#888',
fontSize: 14,
},
unreadMessageText: {
color: '#333',
fontWeight: '500',
},
unreadBadge: {
minWidth: 20,
height: 20,
borderRadius: 10,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
paddingHorizontal: 6,
},
unreadBadgeText: {
color: '#FFF',
fontSize: 12,
fontWeight: '600',
},
});

View File

@@ -3,7 +3,7 @@
* 嵌入式聊天组件 - 用于桌面端双栏布局右侧 * 嵌入式聊天组件 - 用于桌面端双栏布局右侧
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { import {
View, View,
FlatList, FlatList,
@@ -19,7 +19,7 @@ import {
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { colors, spacing, fontSizes, shadows } from '../../../theme'; import { spacing, fontSizes, shadows, useAppColors, type AppColors } from '../../../theme';
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto'; import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common'; import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores'; import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
@@ -34,12 +34,210 @@ import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
const { width: screenWidth } = Dimensions.get('window'); const { width: screenWidth } = Dimensions.get('window');
const PANEL_WIDTH = 360; const PANEL_WIDTH = 360;
function createEmbeddedChatStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.chat.screen,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
height: 56,
},
headerButton: {
padding: spacing.xs,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
marginLeft: spacing.sm,
maxWidth: 200,
},
messageList: {
flex: 1,
backgroundColor: colors.chat.screen,
},
centerContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
emptyText: {
marginTop: spacing.md,
fontSize: 16,
color: colors.text.secondary,
},
emptySubtext: {
marginTop: spacing.xs,
fontSize: 14,
color: colors.text.disabled,
},
listContent: {
padding: spacing.md,
paddingBottom: spacing.xl,
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-end',
marginBottom: spacing.md,
maxWidth: screenWidth * 0.7,
},
messageRowLeft: {
alignSelf: 'flex-start',
},
messageRowRight: {
alignSelf: 'flex-end',
justifyContent: 'flex-end',
},
messageBubble: {
maxWidth: screenWidth * 0.5,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: 18,
marginHorizontal: spacing.sm,
},
messageBubbleMe: {
backgroundColor: colors.chat.replyTint,
borderBottomRightRadius: 4,
},
messageBubbleOther: {
backgroundColor: colors.chat.bubbleIncoming,
borderBottomLeftRadius: 4,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 0.5 },
shadowOpacity: 0.04,
shadowRadius: 1,
elevation: 1,
},
senderName: {
fontSize: 12,
color: colors.text.secondary,
marginBottom: 2,
},
messageText: {
fontSize: 15,
lineHeight: 20,
},
messageTextMe: {
color: colors.chat.textPrimary,
},
messageTextOther: {
color: colors.chat.textPrimary,
},
inputArea: {
backgroundColor: colors.chat.screen,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
},
iconButton: {
padding: spacing.xs,
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
inputContainer: {
flex: 1,
backgroundColor: colors.chat.surfaceInput,
borderRadius: 20,
paddingHorizontal: spacing.md,
minHeight: 40,
justifyContent: 'center',
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
textInput: {
fontSize: 15,
color: colors.text.primary,
padding: 0,
maxHeight: 100,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs,
},
sendButtonDisabled: {
backgroundColor: colors.background.disabled,
},
messageTextRecalled: {
fontStyle: 'italic',
color: colors.text.secondary,
},
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 4,
},
messageImage: {
borderRadius: 8,
marginRight: 4,
marginBottom: 4,
},
imagePlaceholder: {
width: 120,
height: 120,
borderRadius: 8,
backgroundColor: colors.background.disabled,
justifyContent: 'center',
alignItems: 'center',
marginRight: 4,
marginBottom: 4,
},
imagePlaceholderText: {
color: colors.text.secondary,
fontSize: 12,
marginTop: 4,
},
});
}
interface EmbeddedChatProps { interface EmbeddedChatProps {
conversation: ConversationResponse; conversation: ConversationResponse;
onBack: () => void; onBack: () => void;
} }
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => { export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
const colors = useAppColors();
const styles = useMemo(() => createEmbeddedChatStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const currentUser = useAuthStore(state => state.currentUser); const currentUser = useAuthStore(state => state.currentUser);
@@ -411,7 +609,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
<View style={styles.header}> <View style={styles.header}>
{/* 大屏幕(>= 768px时隐藏返回按钮 */} {/* 大屏幕(>= 768px时隐藏返回按钮 */}
{!isWideScreen ? ( {!isWideScreen ? (
<AppBackButton onPress={onBack} style={styles.headerButton} iconColor="#666" /> <AppBackButton onPress={onBack} style={styles.headerButton} iconColor={colors.text.secondary} />
) : ( ) : (
<View style={styles.headerButton} /> <View style={styles.headerButton} />
)} )}
@@ -426,11 +624,11 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */} {/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */}
{isWideScreen && isGroupChat ? ( {isWideScreen && isGroupChat ? (
<TouchableOpacity onPress={toggleGroupInfoPanel} style={styles.headerButton}> <TouchableOpacity onPress={toggleGroupInfoPanel} style={styles.headerButton}>
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#666" /> <MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.secondary} />
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}> <TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" /> <MaterialCommunityIcons name="arrow-expand" size={22} color={colors.text.secondary} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@@ -443,7 +641,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</View> </View>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<MaterialCommunityIcons name="message-text-outline" size={48} color="#DDD" /> <MaterialCommunityIcons name="message-text-outline" size={48} color={colors.text.disabled} />
<Text style={styles.emptyText}></Text> <Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text> <Text style={styles.emptySubtext}></Text>
</View> </View>
@@ -472,7 +670,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
<View style={styles.inputArea}> <View style={styles.inputArea}>
<View style={styles.inputRow}> <View style={styles.inputRow}>
<TouchableOpacity style={styles.iconButton}> <TouchableOpacity style={styles.iconButton}>
<MaterialCommunityIcons name="plus-circle-outline" size={28} color="#666" /> <MaterialCommunityIcons name="plus-circle-outline" size={28} color={colors.text.secondary} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
@@ -480,7 +678,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
ref={inputRef} ref={inputRef}
style={styles.textInput} style={styles.textInput}
placeholder="发送消息..." placeholder="发送消息..."
placeholderTextColor="#999" placeholderTextColor={colors.text.hint}
value={inputText} value={inputText}
onChangeText={setInputText} onChangeText={setInputText}
multiline={false} multiline={false}
@@ -494,7 +692,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</View> </View>
<TouchableOpacity style={styles.iconButton}> <TouchableOpacity style={styles.iconButton}>
<MaterialCommunityIcons name="emoticon-outline" size={28} color="#666" /> <MaterialCommunityIcons name="emoticon-outline" size={28} color={colors.text.secondary} />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -503,9 +701,9 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
disabled={!inputText.trim() || sending} disabled={!inputText.trim() || sending}
> >
{sending ? ( {sending ? (
<ActivityIndicator size="small" color="#FFF" /> <ActivityIndicator size="small" color={colors.text.inverse} />
) : ( ) : (
<MaterialCommunityIcons name="send" size={20} color="#FFF" /> <MaterialCommunityIcons name="send" size={20} color={colors.text.inverse} />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -604,190 +802,3 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</View> </View>
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F7FA',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: '#FFF',
borderBottomWidth: 1,
borderBottomColor: '#E8E8E8',
...shadows.sm,
height: 56,
},
headerButton: {
padding: spacing.xs,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: '#333',
marginLeft: spacing.sm,
maxWidth: 200,
},
messageList: {
flex: 1,
backgroundColor: '#F5F7FA',
},
centerContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
emptyText: {
marginTop: spacing.md,
fontSize: 16,
color: '#999',
},
emptySubtext: {
marginTop: spacing.xs,
fontSize: 14,
color: '#BBB',
},
listContent: {
padding: spacing.md,
paddingBottom: spacing.xl,
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-end',
marginBottom: spacing.md,
maxWidth: screenWidth * 0.7,
},
messageRowLeft: {
alignSelf: 'flex-start',
},
messageRowRight: {
alignSelf: 'flex-end',
justifyContent: 'flex-end',
},
messageBubble: {
maxWidth: screenWidth * 0.5,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: 18,
marginHorizontal: spacing.sm,
},
messageBubbleMe: {
backgroundColor: colors.primary.main,
borderBottomRightRadius: 4,
},
messageBubbleOther: {
backgroundColor: '#FFF',
borderBottomLeftRadius: 4,
...shadows.sm,
},
senderName: {
fontSize: 12,
color: '#999',
marginBottom: 2,
},
messageText: {
fontSize: 15,
lineHeight: 20,
},
messageTextMe: {
color: '#FFF',
},
messageTextOther: {
color: '#333',
},
inputArea: {
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E8E8E8',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
},
iconButton: {
padding: spacing.xs,
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
inputContainer: {
flex: 1,
backgroundColor: '#F5F5F5',
borderRadius: 20,
paddingHorizontal: spacing.md,
minHeight: 40,
justifyContent: 'center',
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
textInput: {
fontSize: 15,
color: '#333',
padding: 0,
maxHeight: 100,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs,
},
sendButtonDisabled: {
backgroundColor: '#E0E0E0',
},
messageTextRecalled: {
fontStyle: 'italic',
color: '#999',
},
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 4,
},
messageImage: {
borderRadius: 8,
marginRight: 4,
marginBottom: 4,
},
imagePlaceholder: {
width: 120,
height: 120,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 4,
marginBottom: 4,
},
imagePlaceholderText: {
color: '#999',
fontSize: 12,
marginTop: 4,
},
});

View File

@@ -1,10 +1,10 @@
import React from 'react'; import React, { useMemo } from 'react';
import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text } from '../../../components/common'; import { Avatar, Text } from '../../../components/common';
import { colors, spacing, borderRadius, shadows } from '../../../theme'; import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../../theme';
interface GroupInfoSummaryCardProps { interface GroupInfoSummaryCardProps {
groupName?: string; groupName?: string;
@@ -14,6 +14,79 @@ interface GroupInfoSummaryCardProps {
memberCountText?: string; memberCountText?: string;
} }
function createGroupRequestSharedStyles(colors: AppColors) {
return StyleSheet.create({
headerCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.md,
...shadows.sm,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
},
groupInfo: {
marginLeft: spacing.lg,
flex: 1,
},
groupName: {
marginBottom: spacing.xs,
fontWeight: '700',
},
groupMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
groupNoText: {
marginTop: spacing.xs,
},
descriptionContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginTop: spacing.md,
},
groupDesc: {
marginLeft: spacing.sm,
flex: 1,
lineHeight: 20,
},
footer: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
flexDirection: 'row',
backgroundColor: colors.background.default,
},
btn: {
flex: 1,
height: 42,
borderRadius: borderRadius.md,
alignItems: 'center',
justifyContent: 'center',
},
reject: {
marginRight: spacing.sm,
backgroundColor: colors.error.light + '25',
borderWidth: 1,
borderColor: colors.error.light,
},
approve: {
marginLeft: spacing.sm,
backgroundColor: colors.primary.main,
},
statusBox: {
alignItems: 'center',
paddingTop: spacing.sm,
backgroundColor: colors.background.default,
},
});
}
export const GroupInfoSummaryCard: React.FC<GroupInfoSummaryCardProps> = ({ export const GroupInfoSummaryCard: React.FC<GroupInfoSummaryCardProps> = ({
groupName, groupName,
groupAvatar, groupAvatar,
@@ -21,12 +94,16 @@ export const GroupInfoSummaryCard: React.FC<GroupInfoSummaryCardProps> = ({
groupDescription, groupDescription,
memberCountText, memberCountText,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createGroupRequestSharedStyles(colors), [colors]);
return ( return (
<View style={styles.headerCard}> <View style={styles.headerCard}>
<View style={styles.groupHeader}> <View style={styles.groupHeader}>
<Avatar source={groupAvatar || ''} size={88} name={groupName} /> <Avatar source={groupAvatar || ''} size={88} name={groupName} />
<View style={styles.groupInfo}> <View style={styles.groupInfo}>
<Text variant="h3" style={styles.groupName} numberOfLines={1}>{groupName || '群聊'}</Text> <Text variant="h3" style={styles.groupName} numberOfLines={1}>
{groupName || '群聊'}
</Text>
<View style={styles.groupMeta}> <View style={styles.groupMeta}>
<MaterialCommunityIcons name="account-group" size={16} color={colors.text.secondary} /> <MaterialCommunityIcons name="account-group" size={16} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary}> <Text variant="caption" color={colors.text.secondary}>
@@ -63,93 +140,34 @@ export const DecisionFooter: React.FC<DecisionFooterProps> = ({
onApprove, onApprove,
processedText = '该请求已处理', processedText = '该请求已处理',
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createGroupRequestSharedStyles(colors), [colors]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (canAction) { if (canAction) {
return ( return (
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}> <View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}>
<TouchableOpacity style={[styles.btn, styles.reject]} onPress={onReject} disabled={submitting}> <TouchableOpacity style={[styles.btn, styles.reject]} onPress={onReject} disabled={submitting}>
<Text variant="body" color={colors.error.main}></Text> <Text variant="body" color={colors.error.main}>
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.approve]} onPress={onApprove} disabled={submitting}> <TouchableOpacity style={[styles.btn, styles.approve]} onPress={onApprove} disabled={submitting}>
{submitting ? <ActivityIndicator color="#fff" /> : <Text variant="body" color="#fff"></Text>} {submitting ? (
<ActivityIndicator color={colors.text.inverse} />
) : (
<Text variant="body" color={colors.text.inverse}>
</Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
} }
return ( return (
<View style={[styles.statusBox, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}> <View style={[styles.statusBox, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}>
<Text variant="caption" color={colors.text.secondary}>{processedText}</Text> <Text variant="caption" color={colors.text.secondary}>
{processedText}
</Text>
</View> </View>
); );
}; };
const styles = StyleSheet.create({
headerCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.md,
...shadows.sm,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
},
groupInfo: {
marginLeft: spacing.lg,
flex: 1,
},
groupName: {
marginBottom: spacing.xs,
fontWeight: '700',
},
groupMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
groupNoText: {
marginTop: spacing.xs,
},
descriptionContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginTop: spacing.md,
},
groupDesc: {
marginLeft: spacing.sm,
flex: 1,
lineHeight: 20,
},
footer: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
flexDirection: 'row',
backgroundColor: colors.background.default,
},
btn: {
flex: 1,
height: 42,
borderRadius: borderRadius.md,
alignItems: 'center',
justifyContent: 'center',
},
reject: {
marginRight: spacing.sm,
backgroundColor: colors.error.light + '25',
borderWidth: 1,
borderColor: colors.error.light,
},
approve: {
marginLeft: spacing.sm,
backgroundColor: colors.primary.main,
},
statusBox: {
alignItems: 'center',
paddingTop: spacing.sm,
backgroundColor: colors.background.default,
},
});

View File

@@ -12,7 +12,7 @@ import {
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../../theme';
import { authService } from '../../../services/authService'; import { authService } from '../../../services/authService';
import { useAuthStore } from '../../../stores'; import { useAuthStore } from '../../../stores';
import { Avatar, EmptyState, Loading, Text } from '../../../components/common'; import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
@@ -41,6 +41,8 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
onClose, onClose,
onConfirm, onConfirm,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createMutualFollowSelectorStyles(colors), [colors]);
const { currentUser } = useAuthStore(); const { currentUser } = useAuthStore();
const [friendList, setFriendList] = useState<User[]>([]); const [friendList, setFriendList] = useState<User[]>([]);
@@ -209,93 +211,96 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
); );
}; };
const styles = StyleSheet.create({ function createMutualFollowSelectorStyles(colors: AppColors) {
modalOverlay: { return StyleSheet.create({
flex: 1, modalOverlay: {
backgroundColor: 'rgba(0, 0, 0, 0.5)', flex: 1,
justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.5)',
}, justifyContent: 'flex-end',
modalContent: { },
backgroundColor: colors.background.paper, modalContent: {
borderTopLeftRadius: borderRadius['2xl'], backgroundColor: colors.background.paper,
borderTopRightRadius: borderRadius['2xl'], borderTopLeftRadius: borderRadius['2xl'],
height: '80%', borderTopRightRadius: borderRadius['2xl'],
paddingHorizontal: spacing.lg, height: '80%',
paddingTop: spacing.lg, paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl, paddingTop: spacing.lg,
}, paddingBottom: spacing.xl,
modalHeader: { },
flexDirection: 'row', modalHeader: {
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'center', justifyContent: 'space-between',
marginBottom: spacing.lg, alignItems: 'center',
}, marginBottom: spacing.lg,
modalHeaderButton: { },
paddingVertical: spacing.sm, modalHeaderButton: {
minWidth: 60, paddingVertical: spacing.sm,
}, minWidth: 60,
modalTitle: { },
fontWeight: '700', modalTitle: {
fontSize: fontSizes.xl, fontWeight: '700',
}, fontSize: fontSizes.xl,
confirmButton: { color: colors.text.primary,
fontWeight: '600', },
textAlign: 'right', confirmButton: {
}, fontWeight: '600',
tipContainer: { textAlign: 'right',
flexDirection: 'row', },
backgroundColor: colors.background.default, tipContainer: {
borderRadius: borderRadius.lg, flexDirection: 'row',
padding: spacing.xs, backgroundColor: colors.background.default,
marginBottom: spacing.md, borderRadius: borderRadius.lg,
}, padding: spacing.xs,
tipText: { marginBottom: spacing.md,
fontWeight: '600', },
}, tipText: {
selectedBadge: { fontWeight: '600',
backgroundColor: colors.primary.light + '20', },
paddingHorizontal: spacing.md, selectedBadge: {
paddingVertical: spacing.xs, backgroundColor: colors.primary.light + '20',
borderRadius: borderRadius.sm, paddingHorizontal: spacing.md,
alignSelf: 'flex-start', paddingVertical: spacing.xs,
marginBottom: spacing.md, borderRadius: borderRadius.sm,
}, alignSelf: 'flex-start',
friendListContent: { marginBottom: spacing.md,
paddingBottom: spacing.xl, },
}, friendListContent: {
friendItem: { paddingBottom: spacing.xl,
flexDirection: 'row', },
alignItems: 'center', friendItem: {
paddingVertical: spacing.md, flexDirection: 'row',
paddingHorizontal: spacing.sm, alignItems: 'center',
borderRadius: borderRadius.lg, paddingVertical: spacing.md,
marginBottom: spacing.xs, paddingHorizontal: spacing.sm,
}, borderRadius: borderRadius.lg,
friendItemSelected: { marginBottom: spacing.xs,
backgroundColor: colors.primary.light + '15', },
}, friendItemSelected: {
friendInfo: { backgroundColor: colors.primary.light + '15',
flex: 1, },
marginLeft: spacing.md, friendInfo: {
marginRight: spacing.sm, flex: 1,
}, marginLeft: spacing.md,
nickname: { marginRight: spacing.sm,
fontWeight: '500', },
marginBottom: 2, nickname: {
}, fontWeight: '500',
checkbox: { marginBottom: 2,
width: 26, },
height: 26, checkbox: {
borderRadius: 13, width: 26,
borderWidth: 2, height: 26,
borderColor: colors.divider, borderRadius: 13,
justifyContent: 'center', borderWidth: 2,
alignItems: 'center', borderColor: colors.divider,
}, justifyContent: 'center',
checkboxSelected: { alignItems: 'center',
backgroundColor: colors.primary.main, },
borderColor: colors.primary.main, checkboxSelected: {
}, backgroundColor: colors.primary.main,
}); borderColor: colors.primary.main,
},
});
}
export default MutualFollowSelectorModal; export default MutualFollowSelectorModal;

View File

@@ -9,7 +9,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
import { authService, resolveAuthApiError } from '../../services/authService'; import { authService, resolveAuthApiError } from '../../services/authService';
@@ -17,6 +17,8 @@ import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
export const AccountSecurityScreen: React.FC = () => { export const AccountSecurityScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createAccountSecurityStyles(colors), [colors]);
const { isWideScreen, isMobile } = useResponsive(); const { isWideScreen, isMobile } = useResponsive();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const currentUser = useAuthStore((state) => state.currentUser); const currentUser = useAuthStore((state) => state.currentUser);
@@ -232,7 +234,7 @@ export const AccountSecurityScreen: React.FC = () => {
disabled={sendingCode || countdown > 0} disabled={sendingCode || countdown > 0}
> >
{sendingCode ? ( {sendingCode ? (
<ActivityIndicator size="small" color="#fff" /> <ActivityIndicator size="small" color={colors.text.inverse} />
) : ( ) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text> <Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)} )}
@@ -244,7 +246,11 @@ export const AccountSecurityScreen: React.FC = () => {
onPress={handleVerifyEmail} onPress={handleVerifyEmail}
disabled={verifyingEmail} disabled={verifyingEmail}
> >
{verifyingEmail ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}></Text>} {verifyingEmail ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -298,7 +304,7 @@ export const AccountSecurityScreen: React.FC = () => {
disabled={sendingChangePwdCode || changePwdCountdown > 0} disabled={sendingChangePwdCode || changePwdCountdown > 0}
> >
{sendingChangePwdCode ? ( {sendingChangePwdCode ? (
<ActivityIndicator size="small" color="#fff" /> <ActivityIndicator size="small" color={colors.text.inverse} />
) : ( ) : (
<Text style={styles.sendCodeButtonText}> <Text style={styles.sendCodeButtonText}>
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'} {changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
@@ -323,7 +329,11 @@ export const AccountSecurityScreen: React.FC = () => {
onPress={handleChangePassword} onPress={handleChangePassword}
disabled={updatingPassword} disabled={updatingPassword}
> >
{updatingPassword ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}></Text>} {updatingPassword ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -343,109 +353,117 @@ export const AccountSecurityScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createAccountSecurityStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
scrollContent: { },
paddingVertical: spacing.md, scrollContent: {
}, paddingVertical: spacing.md,
section: { },
marginBottom: spacing.lg, section: {
}, marginBottom: spacing.lg,
sectionHeader: { },
flexDirection: 'row', sectionHeader: {
alignItems: 'center', flexDirection: 'row',
paddingHorizontal: spacing.lg, alignItems: 'center',
marginBottom: spacing.sm, paddingHorizontal: spacing.lg,
gap: spacing.xs, marginBottom: spacing.sm,
}, gap: spacing.xs,
sectionTitle: { },
fontWeight: '600', sectionTitle: {
}, fontWeight: '600',
card: { },
backgroundColor: colors.background.paper, card: {
marginHorizontal: spacing.lg, backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg, marginHorizontal: spacing.lg,
padding: spacing.md, borderRadius: borderRadius.lg,
}, padding: spacing.md,
statusRow: { },
flexDirection: 'row', statusRow: {
justifyContent: 'space-between', flexDirection: 'row',
alignItems: 'center', justifyContent: 'space-between',
marginBottom: spacing.md, alignItems: 'center',
}, marginBottom: spacing.md,
statusBadge: { },
paddingHorizontal: spacing.sm, statusBadge: {
paddingVertical: 4, paddingHorizontal: spacing.sm,
borderRadius: borderRadius.sm, paddingVertical: 4,
}, borderRadius: borderRadius.sm,
statusVerified: { },
backgroundColor: '#E8F5E9', statusVerified: {
}, backgroundColor: colors.success.light + '40',
statusUnverified: { },
backgroundColor: '#FFF3E0', statusUnverified: {
}, backgroundColor: colors.warning.light + '40',
inputWrapper: { },
flexDirection: 'row', statusVerifiedText: {
alignItems: 'center', color: colors.success.dark,
backgroundColor: colors.background.default, },
borderRadius: borderRadius.lg, statusUnverifiedText: {
borderWidth: 1, color: colors.warning.dark,
borderColor: colors.divider, },
paddingHorizontal: spacing.md, inputWrapper: {
height: 48, flexDirection: 'row',
marginBottom: spacing.sm, alignItems: 'center',
}, backgroundColor: colors.background.default,
inputIcon: { borderRadius: borderRadius.lg,
marginRight: spacing.sm, borderWidth: 1,
}, borderColor: colors.divider,
input: { paddingHorizontal: spacing.md,
flex: 1, height: 48,
color: colors.text.primary, marginBottom: spacing.sm,
fontSize: fontSizes.md, },
}, inputIcon: {
codeRow: { marginRight: spacing.sm,
flexDirection: 'row', },
alignItems: 'center', input: {
gap: spacing.sm, flex: 1,
marginBottom: spacing.sm, color: colors.text.primary,
}, fontSize: fontSizes.md,
codeInput: { },
flex: 1, codeRow: {
marginBottom: 0, flexDirection: 'row',
}, alignItems: 'center',
sendCodeButton: { gap: spacing.sm,
height: 48, marginBottom: spacing.sm,
minWidth: 110, },
borderRadius: borderRadius.lg, codeInput: {
backgroundColor: colors.primary.main, flex: 1,
alignItems: 'center', marginBottom: 0,
justifyContent: 'center', },
paddingHorizontal: spacing.sm, sendCodeButton: {
}, height: 48,
sendCodeButtonText: { minWidth: 110,
color: '#fff', borderRadius: borderRadius.lg,
fontSize: fontSizes.sm, backgroundColor: colors.primary.main,
fontWeight: '600', alignItems: 'center',
}, justifyContent: 'center',
primaryButton: { paddingHorizontal: spacing.sm,
height: 48, },
borderRadius: borderRadius.lg, sendCodeButtonText: {
backgroundColor: colors.primary.main, color: colors.text.inverse,
alignItems: 'center', fontSize: fontSizes.sm,
justifyContent: 'center', fontWeight: '600',
marginTop: spacing.xs, },
}, primaryButton: {
primaryButtonText: { height: 48,
color: '#fff', borderRadius: borderRadius.lg,
fontSize: fontSizes.md, backgroundColor: colors.primary.main,
fontWeight: '700', alignItems: 'center',
}, justifyContent: 'center',
buttonDisabled: { marginTop: spacing.xs,
opacity: 0.6, },
}, primaryButtonText: {
}); color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '700',
},
buttonDisabled: {
opacity: 0.6,
},
});
}
export default AccountSecurityScreen; export default AccountSecurityScreen;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
@@ -12,12 +12,52 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common';
import { authService } from '../../services'; import { authService } from '../../services';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { User } from '../../types'; import { User } from '../../types';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
function createBlockedUsersStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
loadingWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
listContent: {
padding: spacing.md,
gap: spacing.sm,
},
emptyContent: {
flexGrow: 1,
justifyContent: 'center',
},
item: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
...shadows.sm,
},
content: {
flex: 1,
marginLeft: spacing.md,
marginRight: spacing.sm,
},
nickname: {
fontWeight: '600',
},
});
}
export const BlockedUsersScreen: React.FC = () => { export const BlockedUsersScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createBlockedUsersStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -141,40 +181,4 @@ export const BlockedUsersScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
loadingWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
listContent: {
padding: spacing.md,
gap: spacing.sm,
},
emptyContent: {
flexGrow: 1,
justifyContent: 'center',
},
item: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
...shadows.sm,
},
content: {
flex: 1,
marginLeft: spacing.md,
marginRight: spacing.sm,
},
nickname: {
fontWeight: '600',
},
});
export default BlockedUsersScreen; export default BlockedUsersScreen;

View File

@@ -5,7 +5,7 @@
* 表单在宽屏下居中显示 * 表单在宽屏下居中显示
*/ */
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { import {
View, View,
ScrollView, ScrollView,
@@ -22,12 +22,274 @@ import { useNavigation } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; 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 { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common'; import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common';
import { authService, uploadService } from '../../services'; import { authService, uploadService } from '../../services';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
function createEditProfileStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
keyboardView: {
flex: 1,
},
scrollContent: {
padding: spacing.lg,
},
// ===== 预览区域 - 与 UserProfileHeader 完全一致 =====
previewContainer: {
marginBottom: spacing.lg,
},
coverContainer: {
position: 'relative',
overflow: 'hidden',
borderRadius: borderRadius.lg,
},
coverTouchable: {
width: '100%',
height: '100%',
},
coverImage: {
width: '100%',
height: '100%',
},
gradient: {
width: '100%',
height: '100%',
},
coverUploadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditText: {
color: colors.text.inverse,
fontSize: fontSizes.sm,
marginTop: spacing.xs,
fontWeight: '500',
},
waveDecoration: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
},
wave: {
width: '100%',
height: '100%',
backgroundColor: colors.background.paper,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
},
// 用户信息卡片
profileCard: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.md,
marginTop: -50,
borderRadius: borderRadius.xl,
padding: spacing.lg,
...shadows.md,
},
avatarWrapper: {
alignItems: 'center',
marginTop: -60,
marginBottom: spacing.md,
},
avatarContainer: {
position: 'relative',
padding: 4,
backgroundColor: colors.background.paper,
borderRadius: 50,
},
avatarUploadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
},
editAvatarButton: {
position: 'absolute',
bottom: 0,
right: 0,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
userInfo: {
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
marginBottom: spacing.xs,
fontWeight: '700',
},
username: {
marginBottom: spacing.sm,
},
bio: {
textAlign: 'center',
marginTop: spacing.sm,
lineHeight: 20,
},
bioPlaceholder: {
textAlign: 'center',
marginTop: spacing.sm,
fontStyle: 'italic',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: spacing.md,
gap: spacing.sm,
},
metaTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
},
metaTagText: {
marginLeft: spacing.xs,
fontSize: fontSizes.xs,
},
editHint: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm,
gap: spacing.xs,
},
editHintText: {
fontSize: fontSizes.xs,
},
// ===== 表单区域 =====
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.lg,
shadowColor: colors.text.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
sectionTitle: {
marginBottom: spacing.lg,
},
formField: {
flexDirection: 'row',
alignItems: 'flex-start',
},
fieldIconContainer: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
marginTop: spacing.xs,
},
fieldContent: {
flex: 1,
},
fieldLabel: {
marginBottom: spacing.xs,
fontWeight: '500',
},
fieldInput: {
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
disabledInput: {
color: colors.text.disabled,
},
divider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing.md,
marginLeft: 56,
},
// 保存按钮
buttonContainer: {
marginTop: spacing.sm,
},
saveButton: {
backgroundColor: colors.primary.main,
borderRadius: borderRadius.lg,
paddingVertical: spacing.md,
shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonContent: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
buttonIcon: {
marginRight: spacing.sm,
},
saveButtonText: {
color: colors.text.inverse,
},
});
}
type EditProfileSheet = ReturnType<typeof createEditProfileStyles>;
// 表单输入项组件 // 表单输入项组件
interface FormFieldProps { interface FormFieldProps {
icon: string; icon: string;
@@ -41,6 +303,8 @@ interface FormFieldProps {
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url'; keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url';
editable?: boolean; editable?: boolean;
sheet: EditProfileSheet;
colors: AppColors;
} }
const FormField: React.FC<FormFieldProps> = ({ const FormField: React.FC<FormFieldProps> = ({
@@ -55,19 +319,21 @@ const FormField: React.FC<FormFieldProps> = ({
autoCapitalize = 'sentences', autoCapitalize = 'sentences',
keyboardType = 'default', keyboardType = 'default',
editable = true, editable = true,
sheet,
colors,
}) => { }) => {
return ( return (
<View style={styles.formField}> <View style={sheet.formField}>
<View style={styles.fieldIconContainer}> <View style={sheet.fieldIconContainer}>
<MaterialCommunityIcons name={icon as any} size={22} color={colors.primary.main} /> <MaterialCommunityIcons name={icon as any} size={22} color={colors.primary.main} />
</View> </View>
<View style={styles.fieldContent}> <View style={sheet.fieldContent}>
<Text variant="caption" style={styles.fieldLabel}>{label}</Text> <Text variant="caption" style={sheet.fieldLabel}>{label}</Text>
<TextInput <TextInput
style={[ style={[
styles.fieldInput, sheet.fieldInput,
multiline && styles.textArea, multiline && sheet.textArea,
!editable && styles.disabledInput !editable && sheet.disabledInput
]} ]}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
@@ -86,6 +352,8 @@ const FormField: React.FC<FormFieldProps> = ({
}; };
export const EditProfileScreen: React.FC = () => { export const EditProfileScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createEditProfileStyles(colors), [colors]);
const navigation = useNavigation(); const navigation = useNavigation();
const { currentUser, updateUser } = useAuthStore(); const { currentUser, updateUser } = useAuthStore();
const { isWideScreen, isMobile, width } = useResponsive(); const { isWideScreen, isMobile, width } = useResponsive();
@@ -401,6 +669,8 @@ export const EditProfileScreen: React.FC = () => {
onChangeText={setNickname} onChangeText={setNickname}
placeholder="请输入昵称" placeholder="请输入昵称"
maxLength={20} maxLength={20}
sheet={styles}
colors={colors}
/> />
<View style={styles.divider} /> <View style={styles.divider} />
@@ -414,6 +684,8 @@ export const EditProfileScreen: React.FC = () => {
maxLength={100} maxLength={100}
multiline multiline
numberOfLines={3} numberOfLines={3}
sheet={styles}
colors={colors}
/> />
<View style={styles.divider} /> <View style={styles.divider} />
@@ -425,6 +697,8 @@ export const EditProfileScreen: React.FC = () => {
onChangeText={setLocation} onChangeText={setLocation}
placeholder="你所在的城市" placeholder="你所在的城市"
maxLength={30} maxLength={30}
sheet={styles}
colors={colors}
/> />
<View style={styles.divider} /> <View style={styles.divider} />
@@ -438,6 +712,8 @@ export const EditProfileScreen: React.FC = () => {
maxLength={100} maxLength={100}
autoCapitalize="none" autoCapitalize="none"
keyboardType="url" keyboardType="url"
sheet={styles}
colors={colors}
/> />
</View> </View>
@@ -455,6 +731,8 @@ export const EditProfileScreen: React.FC = () => {
placeholder="请输入手机号" placeholder="请输入手机号"
maxLength={11} maxLength={11}
keyboardType="phone-pad" keyboardType="phone-pad"
sheet={styles}
colors={colors}
/> />
<View style={styles.divider} /> <View style={styles.divider} />
@@ -468,6 +746,8 @@ export const EditProfileScreen: React.FC = () => {
maxLength={100} maxLength={100}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
sheet={styles}
colors={colors}
/> />
</View> </View>
@@ -535,254 +815,5 @@ export const EditProfileScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
keyboardView: {
flex: 1,
},
scrollContent: {
padding: spacing.lg,
},
// ===== 预览区域 - 与 UserProfileHeader 完全一致 =====
previewContainer: {
marginBottom: spacing.lg,
},
coverContainer: {
position: 'relative',
overflow: 'hidden',
borderRadius: borderRadius.lg,
},
coverTouchable: {
width: '100%',
height: '100%',
},
coverImage: {
width: '100%',
height: '100%',
},
gradient: {
width: '100%',
height: '100%',
},
coverUploadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
coverEditText: {
color: colors.text.inverse,
fontSize: fontSizes.sm,
marginTop: spacing.xs,
fontWeight: '500',
},
waveDecoration: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
},
wave: {
width: '100%',
height: '100%',
backgroundColor: colors.background.paper,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
},
// 用户信息卡片
profileCard: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.md,
marginTop: -50,
borderRadius: borderRadius.xl,
padding: spacing.lg,
...shadows.md,
},
avatarWrapper: {
alignItems: 'center',
marginTop: -60,
marginBottom: spacing.md,
},
avatarContainer: {
position: 'relative',
padding: 4,
backgroundColor: colors.background.paper,
borderRadius: 50,
},
avatarUploadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
},
editAvatarButton: {
position: 'absolute',
bottom: 0,
right: 0,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
userInfo: {
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
marginBottom: spacing.xs,
fontWeight: '700',
},
username: {
marginBottom: spacing.sm,
},
bio: {
textAlign: 'center',
marginTop: spacing.sm,
lineHeight: 20,
},
bioPlaceholder: {
textAlign: 'center',
marginTop: spacing.sm,
fontStyle: 'italic',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: spacing.md,
gap: spacing.sm,
},
metaTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
},
metaTagText: {
marginLeft: spacing.xs,
fontSize: fontSizes.xs,
},
editHint: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm,
gap: spacing.xs,
},
editHintText: {
fontSize: fontSizes.xs,
},
// ===== 表单区域 =====
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginBottom: spacing.lg,
shadowColor: colors.text.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
sectionTitle: {
marginBottom: spacing.lg,
},
formField: {
flexDirection: 'row',
alignItems: 'flex-start',
},
fieldIconContainer: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
marginTop: spacing.xs,
},
fieldContent: {
flex: 1,
},
fieldLabel: {
marginBottom: spacing.xs,
fontWeight: '500',
},
fieldInput: {
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
disabledInput: {
color: colors.text.disabled,
},
divider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing.md,
marginLeft: 56,
},
// 保存按钮
buttonContainer: {
marginTop: spacing.sm,
},
saveButton: {
backgroundColor: colors.primary.main,
borderRadius: borderRadius.lg,
paddingVertical: spacing.md,
shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonContent: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
buttonIcon: {
marginRight: spacing.sm,
},
saveButtonText: {
color: colors.text.inverse,
},
});
export default EditProfileScreen; export default EditProfileScreen;

View File

@@ -5,7 +5,7 @@
* 在宽屏下使用网格布局 * 在宽屏下使用网格布局
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
View, View,
FlatList, FlatList,
@@ -17,7 +17,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { User } from '../../types'; import { User } from '../../types';
import { useAuthStore, useUserStore } from '../../stores'; import { useAuthStore, useUserStore } from '../../stores';
import { authService } from '../../services'; import { authService } from '../../services';
@@ -26,6 +26,8 @@ import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useColumnCount } from '../../hooks'; import { useResponsive, useColumnCount } from '../../hooks';
const FollowListScreen: React.FC = () => { const FollowListScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createFollowListStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>(); const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>();
const type = typeParam === 'followers' ? 'followers' : 'following'; const type = typeParam === 'followers' ? 'followers' : 'following';
@@ -356,136 +358,138 @@ const FollowListScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createFollowListStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
// 列表布局样式 },
listContent: { // 列表布局样式
flexGrow: 1, listContent: {
paddingHorizontal: spacing.md, flexGrow: 1,
paddingTop: spacing.md, paddingHorizontal: spacing.md,
paddingBottom: spacing.lg, paddingTop: spacing.md,
}, paddingBottom: spacing.lg,
headerCard: { },
backgroundColor: colors.background.paper, headerCard: {
borderRadius: borderRadius.xl, backgroundColor: colors.background.paper,
paddingHorizontal: spacing.lg, borderRadius: borderRadius.xl,
paddingVertical: spacing.md, paddingHorizontal: spacing.lg,
marginBottom: spacing.md, paddingVertical: spacing.md,
borderWidth: 1, marginBottom: spacing.md,
borderColor: colors.divider + '4A', borderWidth: 1,
...shadows.sm, borderColor: colors.divider + '4A',
}, ...shadows.sm,
headerAccent: { },
width: 28, headerAccent: {
height: 3, width: 28,
borderRadius: 999, height: 3,
backgroundColor: colors.primary.main + 'A6', borderRadius: 999,
marginBottom: spacing.sm, backgroundColor: colors.primary.main + 'A6',
}, marginBottom: spacing.sm,
headerMainRow: { },
flexDirection: 'row', headerMainRow: {
alignItems: 'center', flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center',
}, justifyContent: 'space-between',
headerTitle: { },
fontWeight: '700', headerTitle: {
}, fontWeight: '700',
headerCountPill: { },
paddingHorizontal: spacing.sm, headerCountPill: {
paddingVertical: 4, paddingHorizontal: spacing.sm,
borderRadius: borderRadius.full, paddingVertical: 4,
backgroundColor: colors.background.default, borderRadius: borderRadius.full,
}, backgroundColor: colors.background.default,
headerSubtitle: { },
marginTop: spacing.xs, headerSubtitle: {
}, marginTop: spacing.xs,
userItem: { },
flexDirection: 'row', userItem: {
alignItems: 'center', flexDirection: 'row',
backgroundColor: colors.background.paper, alignItems: 'center',
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
padding: spacing.md, borderRadius: borderRadius.lg,
marginBottom: spacing.sm, padding: spacing.md,
borderWidth: 1, marginBottom: spacing.sm,
borderColor: colors.divider + '45', borderWidth: 1,
...shadows.sm, borderColor: colors.divider + '45',
}, ...shadows.sm,
userInfo: { },
flex: 1, userInfo: {
marginLeft: spacing.md, flex: 1,
marginRight: spacing.sm, marginLeft: spacing.md,
}, marginRight: spacing.sm,
nickname: { },
fontWeight: '600', nickname: {
marginBottom: 2, fontWeight: '600',
}, marginBottom: 2,
bio: { },
marginTop: 2, bio: {
}, marginTop: 2,
followButton: { },
minWidth: 82, followButton: {
}, minWidth: 82,
// 网格布局样式 },
gridContent: { // 网格布局样式
flexGrow: 1, gridContent: {
padding: spacing.lg, flexGrow: 1,
}, padding: spacing.lg,
emptyGridContent: { },
flex: 1, emptyGridContent: {
justifyContent: 'center', flex: 1,
alignItems: 'center', justifyContent: 'center',
}, alignItems: 'center',
gridRow: { },
justifyContent: 'flex-start', gridRow: {
gap: spacing.md, justifyContent: 'flex-start',
marginBottom: spacing.md, gap: spacing.md,
}, marginBottom: spacing.md,
userCard: { },
flex: 1, userCard: {
backgroundColor: colors.background.paper, flex: 1,
borderRadius: borderRadius.xl, backgroundColor: colors.background.paper,
padding: spacing.md, borderRadius: borderRadius.xl,
alignItems: 'center', padding: spacing.md,
borderWidth: 1, alignItems: 'center',
borderColor: colors.divider + '45', borderWidth: 1,
...shadows.sm, borderColor: colors.divider + '45',
}, ...shadows.sm,
userCardHeader: { },
marginBottom: spacing.sm, userCardHeader: {
}, marginBottom: spacing.sm,
userCardContent: { },
alignItems: 'center', userCardContent: {
width: '100%', alignItems: 'center',
}, width: '100%',
userCardNickname: { },
fontWeight: '600', userCardNickname: {
marginBottom: 2, fontWeight: '600',
textAlign: 'center', marginBottom: 2,
}, textAlign: 'center',
userCardBio: { },
marginTop: spacing.xs, userCardBio: {
textAlign: 'center', marginTop: spacing.xs,
}, textAlign: 'center',
userCardFooter: { },
marginTop: spacing.md, userCardFooter: {
width: '100%', marginTop: spacing.md,
}, width: '100%',
userCardFollowButton: { },
width: '100%', userCardFollowButton: {
}, width: '100%',
footerLoading: { },
flexDirection: 'row', footerLoading: {
alignItems: 'center', flexDirection: 'row',
justifyContent: 'center', alignItems: 'center',
paddingVertical: spacing.md, justifyContent: 'center',
gap: spacing.xs, paddingVertical: spacing.md,
}, gap: spacing.xs,
footerLoadingText: { },
marginLeft: spacing.xs, footerLoadingText: {
}, marginLeft: spacing.xs,
}); },
});
}
export default FollowListScreen; export default FollowListScreen;

View File

@@ -4,7 +4,7 @@
* 在宽屏下居中显示 * 在宽屏下居中显示
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -13,7 +13,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
import { import {
loadNotificationPreferences, loadNotificationPreferences,
@@ -33,6 +33,8 @@ interface NotificationSettingItem {
} }
export const NotificationSettingsScreen: React.FC = () => { export const NotificationSettingsScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createNotificationSettingsStyles(colors), [colors]);
const [vibrationEnabled, setVibrationEnabledState] = useState(true); const [vibrationEnabled, setVibrationEnabledState] = useState(true);
const [pushEnabled, setPushEnabled] = useState(true); const [pushEnabled, setPushEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true); const [soundEnabled, setSoundEnabled] = useState(true);
@@ -183,72 +185,74 @@ export const NotificationSettingsScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createNotificationSettingsStyles(colors: AppColors) {
container: { return StyleSheet.create({
flex: 1, container: {
backgroundColor: colors.background.default, flex: 1,
}, backgroundColor: colors.background.default,
scrollContent: { },
paddingVertical: spacing.md, scrollContent: {
}, paddingVertical: spacing.md,
section: { },
marginBottom: spacing.lg, section: {
}, marginBottom: spacing.lg,
sectionHeader: { },
flexDirection: 'row', sectionHeader: {
alignItems: 'center', flexDirection: 'row',
paddingHorizontal: spacing.lg, alignItems: 'center',
marginBottom: spacing.sm, paddingHorizontal: spacing.lg,
gap: spacing.xs, marginBottom: spacing.sm,
}, gap: spacing.xs,
sectionTitle: { },
fontWeight: '600', sectionTitle: {
}, fontWeight: '600',
card: { },
backgroundColor: colors.background.paper, card: {
marginHorizontal: spacing.lg, backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg, marginHorizontal: spacing.lg,
overflow: 'hidden', borderRadius: borderRadius.lg,
}, overflow: 'hidden',
settingItem: { },
flexDirection: 'row', settingItem: {
alignItems: 'center', flexDirection: 'row',
paddingVertical: spacing.md, alignItems: 'center',
paddingHorizontal: spacing.md, paddingVertical: spacing.md,
}, paddingHorizontal: spacing.md,
iconContainer: { },
width: 36, iconContainer: {
height: 36, width: 36,
borderRadius: borderRadius.md, height: 36,
backgroundColor: colors.primary.light + '20', borderRadius: borderRadius.md,
justifyContent: 'center', backgroundColor: colors.primary.light + '20',
alignItems: 'center', justifyContent: 'center',
marginRight: spacing.md, alignItems: 'center',
}, marginRight: spacing.md,
settingContent: { },
flex: 1, settingContent: {
}, flex: 1,
subtitle: { },
marginTop: 2, subtitle: {
}, marginTop: 2,
divider: { },
height: 1, divider: {
backgroundColor: colors.divider, height: 1,
marginLeft: 36 + spacing.md + spacing.md, backgroundColor: colors.divider,
}, marginLeft: 36 + spacing.md + spacing.md,
tipContainer: { },
flexDirection: 'row', tipContainer: {
alignItems: 'flex-start', flexDirection: 'row',
marginHorizontal: spacing.lg, alignItems: 'flex-start',
padding: spacing.md, marginHorizontal: spacing.lg,
backgroundColor: colors.background.paper, padding: spacing.md,
borderRadius: borderRadius.lg, backgroundColor: colors.background.paper,
gap: spacing.sm, borderRadius: borderRadius.lg,
}, gap: spacing.sm,
tipText: { },
flex: 1, tipText: {
lineHeight: 20, flex: 1,
}, lineHeight: 20,
}); },
});
}
export default NotificationSettingsScreen; export default NotificationSettingsScreen;

View File

@@ -4,7 +4,7 @@
* 在宽屏下居中显示,最大宽度限制 * 在宽屏下居中显示,最大宽度限制
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -16,7 +16,15 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import {
useAppColors,
spacing,
fontSizes,
borderRadius,
useThemePreference,
useSetThemePreference,
type AppColors,
} from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
@@ -34,11 +42,9 @@ interface SettingsItem {
subtitle?: string; subtitle?: string;
} }
// 获取应用版本号
const APP_VERSION = Constants.expoConfig?.version || '1.0.0'; const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
// 设置分组配置 const SETTINGS_GROUPS_BASE = [
const SETTINGS_GROUPS = [
{ {
title: '账号与安全', title: '账号与安全',
icon: 'shield-check-outline', icon: 'shield-check-outline',
@@ -66,19 +72,161 @@ const SETTINGS_GROUPS = [
}, },
]; ];
function createSettingsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
paddingVertical: spacing.md,
},
groupContainer: {
marginBottom: spacing.lg,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
marginBottom: spacing.sm,
gap: spacing.xs,
},
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
},
card: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
settingItemFirst: {
borderTopLeftRadius: borderRadius.lg,
borderTopRightRadius: borderRadius.lg,
},
settingItemLast: {
borderBottomLeftRadius: borderRadius.lg,
borderBottomRightRadius: borderRadius.lg,
borderBottomWidth: 0,
},
settingItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
dangerIconContainer: {
backgroundColor: colors.error.light + '20',
},
settingContent: {
flex: 1,
},
subtitle: {
marginTop: 2,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
marginBottom: spacing.lg,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
gap: spacing.sm,
},
logoutText: {
fontWeight: '500',
},
footer: {
alignItems: 'center',
marginTop: spacing.xl,
paddingBottom: spacing.xl,
},
copyright: {
marginTop: spacing.xs,
},
});
}
export const SettingsScreen: React.FC = () => { export const SettingsScreen: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { logout } = useAuthStore(); const { logout } = useAuthStore();
const { isWideScreen } = useResponsive(); const { isWideScreen } = useResponsive();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const colors = useAppColors();
// 底部间距,避免被 TabBar 遮挡 const styles = useMemo(() => createSettingsStyles(colors), [colors]);
const themePreference = useThemePreference();
const setThemePreference = useSetThemePreference();
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
// 处理设置项点击 const settingsGroups = useMemo(() => {
const themeSubtitle =
themePreference === 'system' ? '跟随系统' : themePreference === 'dark' ? '深色' : '浅色';
return [
{
title: '显示与外观',
icon: 'palette-outline',
items: [
{
key: 'theme',
title: '主题模式',
icon: 'theme-light-dark',
showArrow: true,
subtitle: themeSubtitle,
},
],
},
...SETTINGS_GROUPS_BASE,
];
}, [themePreference]);
const handleItemPress = (key: string) => { const handleItemPress = (key: string) => {
switch (key) { switch (key) {
case 'theme':
Alert.alert('主题模式', '选择应用界面明暗', [
{ text: '取消', style: 'cancel' },
{
text: '跟随系统',
onPress: () => {
void setThemePreference('system');
},
},
{
text: '浅色',
onPress: () => {
void setThemePreference('light');
},
},
{
text: '深色',
onPress: () => {
void setThemePreference('dark');
},
},
]);
break;
case 'edit_profile': case 'edit_profile':
router.push(hrefs.hrefProfileEdit()); router.push(hrefs.hrefProfileEdit());
break; break;
@@ -103,7 +251,7 @@ export const SettingsScreen: React.FC = () => {
onPress: async () => { onPress: async () => {
await logout(); await logout();
router.replace(hrefs.hrefAuthLogin()); router.replace(hrefs.hrefAuthLogin());
} },
}, },
] ]
); );
@@ -113,7 +261,6 @@ export const SettingsScreen: React.FC = () => {
} }
}; };
// 渲染单个设置项
const renderSettingItem = (item: SettingsItem, index: number, total: number) => ( const renderSettingItem = (item: SettingsItem, index: number, total: number) => (
<TouchableOpacity <TouchableOpacity
key={item.key} key={item.key}
@@ -122,14 +269,11 @@ export const SettingsScreen: React.FC = () => {
index === 0 && styles.settingItemFirst, index === 0 && styles.settingItemFirst,
index === total - 1 && styles.settingItemLast, index === total - 1 && styles.settingItemLast,
]} ]}
onPress={() => item.onPress ? item.onPress() : handleItemPress(item.key)} onPress={() => (item.onPress ? item.onPress() : handleItemPress(item.key))}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.settingItemLeft}> <View style={styles.settingItemLeft}>
<View style={[ <View style={[styles.iconContainer, item.danger && styles.dangerIconContainer]}>
styles.iconContainer,
item.danger && styles.dangerIconContainer
]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name={item.icon as any} name={item.icon as any}
size={20} size={20}
@@ -137,10 +281,7 @@ export const SettingsScreen: React.FC = () => {
/> />
</View> </View>
<View style={styles.settingContent}> <View style={styles.settingContent}>
<Text <Text variant="body" color={item.danger ? colors.error.main : colors.text.primary}>
variant="body"
color={item.danger ? colors.error.main : colors.text.primary}
>
{item.title} {item.title}
</Text> </Text>
{item.subtitle && ( {item.subtitle && (
@@ -156,8 +297,7 @@ export const SettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
); );
// 渲染分组 const renderGroup = (group: (typeof settingsGroups)[0]) => (
const renderGroup = (group: typeof SETTINGS_GROUPS[0], groupIndex: number) => (
<View key={group.title} style={styles.groupContainer}> <View key={group.title} style={styles.groupContainer}>
<View style={styles.groupHeader}> <View style={styles.groupHeader}>
<MaterialCommunityIcons name={group.icon as any} size={16} color={colors.primary.main} /> <MaterialCommunityIcons name={group.icon as any} size={16} color={colors.primary.main} />
@@ -166,14 +306,11 @@ export const SettingsScreen: React.FC = () => {
</Text> </Text>
</View> </View>
<View style={styles.card}> <View style={styles.card}>
{group.items.map((item, index) => {group.items.map((item, index) => renderSettingItem(item, index, group.items.length))}
renderSettingItem(item, index, group.items.length)
)}
</View> </View>
</View> </View>
); );
// 退出登录按钮
const renderLogoutButton = () => ( const renderLogoutButton = () => (
<TouchableOpacity <TouchableOpacity
style={styles.logoutButton} style={styles.logoutButton}
@@ -187,13 +324,11 @@ export const SettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
); );
// 渲染内容
const renderContent = () => ( const renderContent = () => (
<> <>
{SETTINGS_GROUPS.map((group, index) => renderGroup(group, index))} {settingsGroups.map((group) => renderGroup(group))}
{renderLogoutButton()} {renderLogoutButton()}
{/* 底部版权信息 */}
<View style={styles.footer}> <View style={styles.footer}>
<Text variant="caption" color={colors.text.hint}> <Text variant="caption" color={colors.text.hint}>
v{APP_VERSION} v{APP_VERSION}
@@ -222,98 +357,4 @@ export const SettingsScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
scrollContent: {
paddingVertical: spacing.md,
},
groupContainer: {
marginBottom: spacing.lg,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
marginBottom: spacing.sm,
gap: spacing.xs,
},
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
},
card: {
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
settingItemFirst: {
borderTopLeftRadius: borderRadius.lg,
borderTopRightRadius: borderRadius.lg,
},
settingItemLast: {
borderBottomLeftRadius: borderRadius.lg,
borderBottomRightRadius: borderRadius.lg,
borderBottomWidth: 0,
},
settingItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.light + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
dangerIconContainer: {
backgroundColor: colors.error.light + '20',
},
settingContent: {
flex: 1,
},
subtitle: {
marginTop: 2,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.paper,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
marginBottom: spacing.lg,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
gap: spacing.sm,
},
logoutText: {
fontWeight: '500',
},
footer: {
alignItems: 'center',
marginTop: spacing.xl,
paddingBottom: spacing.xl,
},
copyright: {
marginTop: spacing.xs,
},
});
export default SettingsScreen; export default SettingsScreen;

View File

@@ -12,12 +12,12 @@ import {
ScrollView, ScrollView,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../../theme'; import { useAppColors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { PostCard, TabBar, UserProfileHeader } from '../../components/business'; import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common'; import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './useUserProfile'; import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
interface UserProfileScreenProps { interface UserProfileScreenProps {
mode: ProfileMode; mode: ProfileMode;
@@ -25,6 +25,8 @@ interface UserProfileScreenProps {
} }
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId }) => { export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId }) => {
const colors = useAppColors();
const sharedStyles = useMemo(() => createSharedProfileStyles(colors), [colors]);
const { isDesktop, isTablet } = useResponsive(); const { isDesktop, isTablet } = useResponsive();
const { const {

View File

@@ -16,5 +16,5 @@ export { BlockedUsersScreen } from './BlockedUsersScreen';
export { AccountSecurityScreen } from './AccountSecurityScreen'; export { AccountSecurityScreen } from './AccountSecurityScreen';
// 导出 Hook 供需要自定义的场景使用 // 导出 Hook 供需要自定义的场景使用
export { useUserProfile, sharedStyles, TABS, TAB_ICONS } from './useUserProfile'; export { useUserProfile, createSharedProfileStyles, TABS, TAB_ICONS } from './useUserProfile';
export type { ProfileMode, UseUserProfileOptions, UseUserProfileReturn } from './useUserProfile'; export type { ProfileMode, UseUserProfileOptions, UseUserProfileReturn } from './useUserProfile';

View File

@@ -4,9 +4,10 @@
*/ */
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { StyleSheet } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing } from '../../theme'; import { spacing, type AppColors } from '../../theme';
import { Post, User } from '../../types'; import { Post, User } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore'; import { useCurrentUser } from '../../stores/authStore';
@@ -443,62 +444,61 @@ export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileRe
}; };
}; };
// 共享的样式 export function createSharedProfileStyles(colors: AppColors) {
import { StyleSheet } from 'react-native'; return StyleSheet.create({
container: {
export const sharedStyles = StyleSheet.create({ flex: 1,
container: { backgroundColor: colors.background.default,
flex: 1, },
backgroundColor: colors.background.default, scrollContent: {
}, flexGrow: 1,
scrollContent: { },
flexGrow: 1, desktopContainer: {
}, flex: 1,
desktopContainer: { flexDirection: 'row',
flex: 1, gap: spacing.lg,
flexDirection: 'row', padding: spacing.lg,
gap: spacing.lg, },
padding: spacing.lg, desktopSidebar: {
}, width: 380,
desktopSidebar: { flexShrink: 0,
width: 380, },
flexShrink: 0, desktopContent: {
}, flex: 1,
desktopContent: { minWidth: 0,
flex: 1, },
minWidth: 0, desktopScrollContent: {
}, flexGrow: 1,
desktopScrollContent: { },
flexGrow: 1, tabBarContainer: {
}, marginTop: spacing.xs,
tabBarContainer: { marginBottom: 2,
marginTop: spacing.xs, },
marginBottom: 2, contentContainer: {
}, flex: 1,
contentContainer: { minHeight: 350,
flex: 1, paddingTop: spacing.xs,
minHeight: 350, },
paddingTop: spacing.xs, postsContainer: {
}, paddingHorizontal: spacing.md,
postsContainer: { paddingTop: spacing.sm,
paddingHorizontal: spacing.md, },
paddingTop: spacing.sm, postWrapper: {
}, marginBottom: spacing.md,
postWrapper: { backgroundColor: colors.background.paper,
marginBottom: spacing.md, borderRadius: 16,
backgroundColor: colors.background.paper, overflow: 'hidden',
borderRadius: 16, shadowColor: colors.chat.shadow,
overflow: 'hidden', shadowOffset: { width: 0, height: 2 },
shadowColor: '#000', shadowOpacity: 0.06,
shadowOffset: { width: 0, height: 2 }, shadowRadius: 8,
shadowOpacity: 0.06, elevation: 2,
shadowRadius: 8, },
elevation: 2, lastPost: {
}, marginBottom: spacing['2xl'],
lastPost: { },
marginBottom: spacing['2xl'], });
}, }
});
// Tab 配置 // Tab 配置
export const TABS = ['帖子', '收藏']; export const TABS = ['帖子', '收藏'];

View File

@@ -1,10 +1,10 @@
import React from 'react'; import React, { useMemo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Pressable } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius, fontSizes, shadows } from '../../theme'; import { spacing, borderRadius, fontSizes, shadows, useAppColors, type AppColors } from '../../theme';
import { routePayloadCache } from '../../stores/routePayloadCache'; import { routePayloadCache } from '../../stores/routePayloadCache';
import { scheduleService } from '../../services/scheduleService'; import { scheduleService } from '../../services/scheduleService';
@@ -32,15 +32,146 @@ const formatWeekRanges = (weeks: number[]) => {
return ranges.join(', '); return ranges.join(', ');
}; };
function createCourseDetailStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
},
mask: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.32)',
justifyContent: 'center',
paddingHorizontal: spacing.lg,
},
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
maxHeight: '82%',
...shadows.lg,
},
contentScroll: {
maxHeight: '100%',
},
contentScrollContainer: {
paddingBottom: spacing.xs,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}AA`,
paddingBottom: spacing.md,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
},
iconBadge: {
width: 30,
height: 30,
borderRadius: borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `${colors.primary.main}12`,
marginRight: spacing.sm,
},
title: {
fontSize: fontSizes.xl,
fontWeight: '700',
color: colors.text.primary,
},
closeButton: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: borderRadius.full,
backgroundColor: colors.background.default,
},
row: {
marginBottom: spacing.lg,
flexDirection: 'row',
alignItems: 'flex-start',
},
rowLast: {
marginBottom: 0,
},
label: {
width: 76,
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.secondary,
lineHeight: 22,
marginTop: 1,
},
value: {
flex: 1,
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
lineHeight: 24,
},
timeList: {
flex: 1,
gap: spacing.sm,
},
timeItemCard: {
borderWidth: 1,
borderColor: `${colors.primary.main}20`,
borderRadius: borderRadius.md,
backgroundColor: `${colors.primary.main}08`,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
timeItemMain: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.primary,
lineHeight: 20,
},
timeItemSub: {
marginTop: spacing.xs,
fontSize: fontSizes.sm,
color: colors.text.secondary,
lineHeight: 18,
},
actionRow: {
marginTop: spacing.lg,
alignItems: 'flex-end',
},
deleteButton: {
height: 38,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.full,
backgroundColor: `${colors.error.main}10`,
borderWidth: 1,
borderColor: `${colors.error.main}25`,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
deleteButtonText: {
fontSize: fontSizes.sm,
fontWeight: '700',
color: colors.error.main,
},
});
}
const CourseDetailScreen: React.FC = () => { const CourseDetailScreen: React.FC = () => {
const router = useRouter(); const router = useRouter();
const colors = useAppColors();
const styles = useMemo(() => createCourseDetailStyles(colors), [colors]);
const { courseId } = useLocalSearchParams<{ courseId?: string }>(); const { courseId } = useLocalSearchParams<{ courseId?: string }>();
const cached = courseId ? routePayloadCache.getCourseDetail(courseId) : undefined; const cached = courseId ? routePayloadCache.getCourseDetail(courseId) : undefined;
const course = cached?.course; const course = cached?.course;
const relatedCourses = cached?.relatedCourses ?? []; const relatedCourses = cached?.relatedCourses ?? [];
const timeLines = const timeLines = relatedCourses.length > 0 ? relatedCourses : course ? [course] : [];
relatedCourses.length > 0 ? relatedCourses : course ? [course] : [];
const handleDeleteCourse = () => { const handleDeleteCourse = () => {
if (!course) return; if (!course) return;
@@ -98,7 +229,9 @@ const CourseDetailScreen: React.FC = () => {
> >
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.label}></Text> <Text style={styles.label}></Text>
<Text style={styles.value} numberOfLines={2}>{course.name}</Text> <Text style={styles.value} numberOfLines={2}>
{course.name}
</Text>
</View> </View>
<View style={[styles.row, styles.rowLast]}> <View style={[styles.row, styles.rowLast]}>
@@ -110,9 +243,7 @@ const CourseDetailScreen: React.FC = () => {
{formatWeekRanges(item.weeks)} · {WEEKDAY_NAMES[item.dayOfWeek]} {formatWeekRanges(item.weeks)} · {WEEKDAY_NAMES[item.dayOfWeek]}
{getMergedSectionIndex(item.startSection)} {getMergedSectionIndex(item.startSection)}
</Text> </Text>
<Text style={styles.timeItemSub}> <Text style={styles.timeItemSub}>{item.teacher || '未填写'}</Text>
{item.teacher || '未填写'}
</Text>
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null} {item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
</View> </View>
))} ))}
@@ -132,132 +263,4 @@ const CourseDetailScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
},
mask: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.32)',
justifyContent: 'center',
paddingHorizontal: spacing.lg,
},
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
maxHeight: '82%',
...shadows.lg,
},
contentScroll: {
maxHeight: '100%',
},
contentScrollContainer: {
paddingBottom: spacing.xs,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}AA`,
paddingBottom: spacing.md,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
},
iconBadge: {
width: 30,
height: 30,
borderRadius: borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `${colors.primary.main}12`,
marginRight: spacing.sm,
},
title: {
fontSize: fontSizes.xl,
fontWeight: '700',
color: colors.text.primary,
},
closeButton: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: borderRadius.full,
backgroundColor: colors.background.default,
},
row: {
marginBottom: spacing.lg,
flexDirection: 'row',
alignItems: 'flex-start',
},
rowLast: {
marginBottom: 0,
},
label: {
width: 76,
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.secondary,
lineHeight: 22,
marginTop: 1,
},
value: {
flex: 1,
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
lineHeight: 24,
},
timeList: {
flex: 1,
gap: spacing.sm,
},
timeItemCard: {
borderWidth: 1,
borderColor: `${colors.primary.main}20`,
borderRadius: borderRadius.md,
backgroundColor: `${colors.primary.main}08`,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
timeItemMain: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.primary,
lineHeight: 20,
},
timeItemSub: {
marginTop: spacing.xs,
fontSize: fontSizes.sm,
color: colors.text.secondary,
lineHeight: 18,
},
actionRow: {
marginTop: spacing.lg,
alignItems: 'flex-end',
},
deleteButton: {
height: 38,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.full,
backgroundColor: `${colors.error.main}10`,
borderWidth: 1,
borderColor: `${colors.error.main}25`,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
deleteButtonText: {
fontSize: fontSizes.sm,
fontWeight: '700',
color: colors.error.main,
},
});
export default CourseDetailScreen; export default CourseDetailScreen;

View File

@@ -23,7 +23,14 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { colors, fontSizes, spacing, borderRadius, shadows } from '../../theme'; import {
fontSizes,
spacing,
borderRadius,
shadows,
useAppColors,
type AppColors,
} from '../../theme';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { import {
Course, Course,
@@ -148,6 +155,8 @@ const getWeekDates = (weekOffset: number = 0) => {
const INITIAL_WEEK = getCurrentWeekNumber(); const INITIAL_WEEK = getCurrentWeekNumber();
export const ScheduleScreen: React.FC = () => { export const ScheduleScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createScheduleStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
// 使用响应式 hook 检测屏幕尺寸 // 使用响应式 hook 检测屏幕尺寸
const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive(); const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive();
@@ -1063,7 +1072,8 @@ export const ScheduleScreen: React.FC = () => {
); );
}; };
const styles = StyleSheet.create({ function createScheduleStyles(colors: AppColors) {
return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
@@ -1483,6 +1493,7 @@ const styles = StyleSheet.create({
color: colors.text.secondary, color: colors.text.secondary,
fontWeight: '500', fontWeight: '500',
}, },
}); });
}
export default ScheduleScreen; export default ScheduleScreen;

View File

@@ -39,7 +39,9 @@ export const installAlertOverride = () => {
return; return;
} }
if (actionButtons.length <= 3) { // 自定义对话框支持多按钮(含「主题模式」等 4 项Web 上原生 Alert 常不可用
const MAX_CUSTOM_DIALOG_ACTIONS = 8;
if (actionButtons.length <= MAX_CUSTOM_DIALOG_ACTIONS) {
showDialog({ showDialog({
title: nextTitle, title: nextTitle,
message: nextMessage, message: nextMessage,
@@ -49,7 +51,6 @@ export const installAlertOverride = () => {
return; return;
} }
// 极少数复杂按钮场景,保底回退原生 Alert
nativeAlert(nextTitle, nextMessage, buttons, options); nativeAlert(nextTitle, nextMessage, buttons, options);
}; };
}; };

View File

@@ -44,6 +44,17 @@ export { userManager } from './userManager';
export { export {
useHomeTabBarVisibilityStore, useHomeTabBarVisibilityStore,
} from './homeTabBarVisibilityStore'; } from './homeTabBarVisibilityStore';
export {
useAppColors,
useThemePreference,
useResolvedColorScheme,
useSetThemePreference,
usePaperThemeFromStore,
ThemeBootstrap,
useThemeStore,
type ThemePreference,
type ResolvedScheme,
} from './themeStore';
export { export {
useConversations as useMessageManagerConversations, useConversations as useMessageManagerConversations,
useMessages, useMessages,

133
src/stores/themeStore.ts Normal file
View File

@@ -0,0 +1,133 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useColorScheme } from 'react-native';
import { create } from 'zustand';
import { useEffect } from 'react';
import { lightColors, darkColors, type AppColors } from '../theme/palettes';
import { buildPaperTheme } from '../theme/paperTheme';
import type { MD3Theme } from 'react-native-paper';
const STORAGE_KEY = 'app_theme_preference';
export type ThemePreference = 'light' | 'dark' | 'system';
export type ResolvedScheme = 'light' | 'dark';
function computeResolved(pref: ThemePreference, system: ResolvedScheme): ResolvedScheme {
return pref === 'system' ? system : pref;
}
function buildState(
preference: ThemePreference,
systemScheme: ResolvedScheme
): {
resolvedScheme: ResolvedScheme;
colors: AppColors;
paperTheme: MD3Theme;
} {
const resolvedScheme = computeResolved(preference, systemScheme);
const isDark = resolvedScheme === 'dark';
const colors = isDark ? darkColors : lightColors;
return {
resolvedScheme,
colors,
paperTheme: buildPaperTheme(colors, isDark),
};
}
type ThemeState = {
preference: ThemePreference;
systemScheme: ResolvedScheme;
hydrated: boolean;
resolvedScheme: ResolvedScheme;
colors: AppColors;
paperTheme: MD3Theme;
setPreference: (p: ThemePreference) => Promise<void>;
setSystemScheme: (s: ResolvedScheme) => void;
hydrate: () => Promise<void>;
};
const initialSystem: ResolvedScheme = 'light';
export const useThemeStore = create<ThemeState>((set, get) => ({
preference: 'system',
systemScheme: initialSystem,
hydrated: false,
...buildState('system', initialSystem),
setSystemScheme: (systemScheme) => {
const { preference, systemScheme: cur } = get();
if (cur === systemScheme) return;
set({
systemScheme,
...buildState(preference, systemScheme),
});
},
setPreference: async (preference) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, preference);
} catch {
/* ignore */
}
const { systemScheme } = get();
set({
preference,
...buildState(preference, systemScheme),
});
},
hydrate: async () => {
let preference: ThemePreference = 'system';
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw === 'light' || raw === 'dark' || raw === 'system') {
preference = raw;
}
} catch {
/* ignore */
}
const { systemScheme } = get();
set({
preference,
hydrated: true,
...buildState(preference, systemScheme),
});
},
}));
/** 在根布局中同步系统配色并恢复持久化偏好 */
export function ThemeBootstrap() {
const system = useColorScheme();
const setSystemScheme = useThemeStore((s) => s.setSystemScheme);
const hydrate = useThemeStore((s) => s.hydrate);
useEffect(() => {
void hydrate();
}, [hydrate]);
useEffect(() => {
setSystemScheme(system === 'dark' ? 'dark' : 'light');
}, [system, setSystemScheme]);
return null;
}
export function useAppColors() {
return useThemeStore((s) => s.colors);
}
export function useThemePreference() {
return useThemeStore((s) => s.preference);
}
export function useResolvedColorScheme() {
return useThemeStore((s) => s.resolvedScheme);
}
export function useSetThemePreference() {
return useThemeStore((s) => s.setPreference);
}
export function usePaperThemeFromStore() {
return useThemeStore((s) => s.paperTheme);
}

View File

@@ -1,183 +1,28 @@
/** /**
* 胡萝卜BBS主题系统 * 胡萝卜BBS 主题明暗色板、Paper 主题、设计 token
* 基于胡萝卜橙主色调的设计系统 * 新代码请优先使用 useAppColors();静态 colors 为浅色快照,与深色模式不同步。
*/ */
// ==================== 颜色系统 ==================== export { lightColors, darkColors, type AppColors } from './palettes';
export const colors = { export { buildPaperTheme } from './paperTheme';
primary: { export * from './tokens';
main: '#FF6B35', // 橙色(主色)
light: '#FFB733',
dark: '#CC8400',
contrast: '#FFFFFF',
},
secondary: {
main: '#4CAF50',
light: '#80E27E',
dark: '#087F23',
},
neutral: {
main: '#6B7280', // 中性灰(辅助色)
light: '#9CA3AF',
dark: '#374151',
bg: '#F3F4F6', // 浅灰背景
bgDark: '#1F2937', // 深灰背景
},
accent: {
main: '#E57373', // 低饱和红(点缀色)
light: '#FFCDD2',
dark: '#C62828',
},
background: {
default: '#F5F5F5',
paper: '#FFFFFF',
disabled: '#E0E0E0',
},
text: {
primary: '#212121',
secondary: '#757575',
disabled: '#9E9E9E',
hint: '#BDBDBD',
inverse: '#FFFFFF',
},
divider: '#E0E0E0',
error: {
main: '#F44336',
light: '#FF7961',
dark: '#D32F2F',
},
success: {
main: '#4CAF50',
light: '#80E27E',
dark: '#087F23',
},
warning: {
main: '#FF9800',
light: '#FFB74D',
dark: '#F57C00',
},
info: {
main: '#2196F3',
light: '#64B5F6',
dark: '#1976D2',
},
};
// ==================== 字体系统 ==================== export {
export const fontSizes = { useAppColors,
xs: 10, useThemePreference,
sm: 12, useResolvedColorScheme,
md: 14, useSetThemePreference,
lg: 16, usePaperThemeFromStore,
xl: 18, ThemeBootstrap,
'2xl': 20, useThemeStore,
'3xl': 24, type ThemePreference,
'4xl': 30, type ResolvedScheme,
}; } from '../stores/themeStore';
// ==================== 间距系统4px基准==================== import { lightColors } from './palettes';
export const spacing = { import { buildPaperTheme } from './paperTheme';
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
'2xl': 24,
'3xl': 32,
'4xl': 40,
'5xl': 48,
'6xl': 56,
};
// ==================== 圆角系统 ==================== /** 浅色静态快照;随主题切换请使用 useAppColors() */
export const borderRadius = { export const colors = lightColors;
sm: 4,
md: 8,
lg: 12,
xl: 16,
'2xl': 24,
full: 9999,
};
// ==================== 阴影系统 ==================== export const paperTheme = buildPaperTheme(lightColors, false);
export const shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.18,
shadowRadius: 1.0,
elevation: 1
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.22,
shadowRadius: 2.22,
elevation: 3
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 6
},
};
// ==================== 导出主题对象 ====================
export const theme = {
colors,
fontSizes,
spacing,
borderRadius,
shadows
};
export type Theme = typeof theme;
// ==================== 主题配置用于React Native Paper====================
export const paperTheme = {
colors: {
primary: colors.primary.main,
primaryContainer: colors.primary.light,
secondary: colors.secondary.main,
secondaryContainer: colors.secondary.light,
tertiary: colors.info.main,
tertiaryContainer: colors.info.light,
surface: colors.background.paper,
surfaceVariant: colors.background.default,
surfaceDisabled: colors.background.disabled,
background: colors.background.default,
error: colors.error.main,
errorContainer: colors.error.light,
onPrimary: colors.primary.contrast,
onPrimaryContainer: colors.primary.dark,
onSecondary: colors.text.inverse,
onSecondaryContainer: colors.secondary.dark,
onTertiary: colors.text.inverse,
onTertiaryContainer: colors.info.dark,
onSurface: colors.text.primary,
onSurfaceVariant: colors.text.secondary,
onSurfaceDisabled: colors.text.disabled,
onError: colors.text.inverse,
onErrorContainer: colors.error.dark,
onBackground: colors.text.primary,
outline: colors.divider,
outlineVariant: colors.divider,
inverseSurface: colors.text.primary,
inverseOnSurface: colors.text.inverse,
inversePrimary: colors.primary.light,
shadow: '#000000',
scrim: '#000000',
backdrop: 'rgba(0, 0, 0, 0.5)',
elevation: {
level0: 'transparent',
level1: colors.background.paper,
level2: colors.background.paper,
level3: colors.background.paper,
level4: colors.background.paper,
level5: colors.background.paper,
},
},
roundness: borderRadius.md,
};

185
src/theme/palettes.ts Normal file
View File

@@ -0,0 +1,185 @@
/**
* 浅色 / 深色色板与聊天场景语义色
*/
export const lightColors = {
primary: {
main: '#FF6B35',
light: '#FFB733',
dark: '#CC8400',
contrast: '#FFFFFF',
},
secondary: {
main: '#4CAF50',
light: '#80E27E',
dark: '#087F23',
},
neutral: {
main: '#6B7280',
light: '#9CA3AF',
dark: '#374151',
bg: '#F3F4F6',
bgDark: '#1F2937',
},
accent: {
main: '#E57373',
light: '#FFCDD2',
dark: '#C62828',
},
background: {
default: '#F5F5F5',
paper: '#FFFFFF',
disabled: '#E0E0E0',
},
text: {
primary: '#212121',
secondary: '#757575',
disabled: '#9E9E9E',
hint: '#BDBDBD',
inverse: '#FFFFFF',
},
divider: '#E0E0E0',
error: {
main: '#F44336',
light: '#FF7961',
dark: '#D32F2F',
},
success: {
main: '#4CAF50',
light: '#80E27E',
dark: '#087F23',
},
warning: {
main: '#FF9800',
light: '#FFB74D',
dark: '#F57C00',
},
info: {
main: '#2196F3',
light: '#64B5F6',
dark: '#1976D2',
},
chat: {
screen: '#F0F2F5',
card: '#FFFFFF',
surfaceRaised: '#F7F8FA',
surfaceMuted: '#F5F7FA',
surfaceInput: '#F5F5F5',
border: '#E8E8E8',
borderLight: '#ECEFF3',
borderHairline: '#F0F0F0',
textPrimary: '#1A1A1A',
textSecondary: '#8E8E93',
textTertiary: '#666666',
textPlaceholder: '#AAAAAA',
textMuted: '#999999',
link: '#4A88C7',
success: '#34C759',
danger: '#FF3B30',
bubbleOutgoing: '#E8E8E8',
bubbleIncoming: '#FFFFFF',
replyTint: '#DFF2FF',
replyTintActive: '#CFEAFF',
replyBorder: '#7FB6E6',
menuHighlight: '#EEF5FC',
iconMuted: '#BBBBBB',
iconSoft: '#C8C8C8',
sheetGrip: '#E0E0E0',
warningBg: '#FFF5F5',
tipBg: '#FFF9E6',
overlayQuote: '#F0F7FF',
shadow: '#000000',
},
} as const;
export type AppColors = typeof lightColors;
export const darkColors = {
primary: {
main: '#FF8A50',
light: '#FFB733',
dark: '#E85D2A',
contrast: '#FFFFFF',
},
secondary: {
main: '#66BB6A',
light: '#81C784',
dark: '#388E3C',
},
neutral: {
main: '#9CA3AF',
light: '#D1D5DB',
dark: '#6B7280',
bg: '#374151',
bgDark: '#111827',
},
accent: {
main: '#EF9A9A',
light: '#FFCDD2',
dark: '#E57373',
},
background: {
default: '#121212',
paper: '#1E1E1E',
disabled: '#2C2C2C',
},
text: {
primary: '#ECECEC',
secondary: '#A8A8A8',
disabled: '#6E6E6E',
hint: '#888888',
inverse: '#121212',
},
divider: '#333333',
error: {
main: '#EF5350',
light: '#FF867C',
dark: '#C62828',
},
success: {
main: '#66BB6A',
light: '#81C784',
dark: '#388E3C',
},
warning: {
main: '#FFA726',
light: '#FFCC80',
dark: '#F57C00',
},
info: {
main: '#42A5F5',
light: '#90CAF9',
dark: '#1976D2',
},
chat: {
screen: '#0A0A0A',
card: '#1C1C1E',
surfaceRaised: '#2C2C2E',
surfaceMuted: '#252528',
surfaceInput: '#2A2A2C',
border: '#38383A',
borderLight: '#3A3A3C',
borderHairline: '#2C2C2E',
textPrimary: '#F2F2F7',
textSecondary: '#98989D',
textTertiary: '#AEAEB2',
textPlaceholder: '#636366',
textMuted: '#8E8E93',
link: '#5AC8FA',
success: '#32D74B',
danger: '#FF453A',
bubbleOutgoing: '#3A3A3C',
bubbleIncoming: '#2C2C2E',
replyTint: '#1A3A52',
replyTintActive: '#224060',
replyBorder: '#4A88C7',
menuHighlight: '#1C2A3A',
iconMuted: '#8E8E93',
iconSoft: '#636366',
sheetGrip: '#48484A',
warningBg: '#3A2222',
tipBg: '#3A3520',
overlayQuote: '#1A2838',
shadow: '#000000',
},
} as unknown as AppColors;

54
src/theme/paperTheme.ts Normal file
View File

@@ -0,0 +1,54 @@
import { MD3DarkTheme, MD3LightTheme, type MD3Theme } from 'react-native-paper';
import { borderRadius } from './tokens';
import type { AppColors } from './palettes';
export function buildPaperTheme(colors: AppColors, isDark: boolean): MD3Theme {
const base = isDark ? MD3DarkTheme : MD3LightTheme;
return {
...base,
colors: {
...base.colors,
primary: colors.primary.main,
primaryContainer: colors.primary.light,
secondary: colors.secondary.main,
secondaryContainer: colors.secondary.light,
tertiary: colors.info.main,
tertiaryContainer: colors.info.light,
surface: colors.background.paper,
surfaceVariant: colors.background.default,
surfaceDisabled: colors.background.disabled,
background: colors.background.default,
error: colors.error.main,
errorContainer: colors.error.light,
onPrimary: colors.primary.contrast,
onPrimaryContainer: colors.primary.dark,
onSecondary: colors.text.inverse,
onSecondaryContainer: colors.secondary.dark,
onTertiary: colors.text.inverse,
onTertiaryContainer: colors.info.dark,
onSurface: colors.text.primary,
onSurfaceVariant: colors.text.secondary,
onSurfaceDisabled: colors.text.disabled,
onError: colors.text.inverse,
onErrorContainer: colors.error.dark,
onBackground: colors.text.primary,
outline: colors.divider,
outlineVariant: colors.divider,
inverseSurface: colors.text.primary,
inverseOnSurface: colors.text.inverse,
inversePrimary: colors.primary.light,
shadow: '#000000',
scrim: '#000000',
backdrop: 'rgba(0, 0, 0, 0.5)',
elevation: {
level0: 'transparent',
level1: colors.background.paper,
level2: colors.background.paper,
level3: colors.background.paper,
level4: colors.background.paper,
level5: colors.background.paper,
},
},
roundness: borderRadius.md,
};
}

60
src/theme/tokens.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* 与明暗无关的设计 token
*/
export const fontSizes = {
xs: 10,
sm: 12,
md: 14,
lg: 16,
xl: 18,
'2xl': 20,
'3xl': 24,
'4xl': 30,
};
export const spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
'2xl': 24,
'3xl': 32,
'4xl': 40,
'5xl': 48,
'6xl': 56,
};
export const borderRadius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
'2xl': 24,
full: 9999,
};
export const shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.18,
shadowRadius: 1.0,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.22,
shadowRadius: 2.22,
elevation: 3,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 6,
},
};