- Add isNetworkError() helper and FetchCurrentUserResult discriminated union to prevent logout on network failures
- Refactor authService to return { kind: 'user' } | { kind: 'auth_failed' } | { kind: 'network_error' }
- Update authStore to preserve login state on network errors, only logout on auth failures
- Replace WSMessageHandler with RealtimeIngestionPipeline for improved real-time message handling
- Remove MessageDeduplication service; add store-level idempotent addOrReplaceMessage for message dedup
- Add atomic systemUnreadCount operations to prevent race conditions
- Add SearchHeader component for consistent search UI across screens
- Add 'home' TabBar variant with underline style matching HomeScreen
- Add Jest test infrastructure and exclude tests from tsconfig
- Fix ChatScreen history lock being stuck when scrolling to latest messages
- Remove unused call_participant_joined/left WS event types
389 lines
11 KiB
TypeScript
389 lines
11 KiB
TypeScript
/**
|
||
* TabBar 标签栏组件 - 美化版
|
||
* 用于切换不同标签页,支持多种样式变体
|
||
* 新增胶囊式、分段式等现代设计风格
|
||
*/
|
||
|
||
import React, { ReactNode, useMemo } from 'react';
|
||
import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native';
|
||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||
import Text from '../common/Text';
|
||
|
||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern' | 'home';
|
||
|
||
interface TabBarProps {
|
||
tabs: string[];
|
||
activeIndex: number;
|
||
onTabChange: (index: number) => void;
|
||
scrollable?: boolean;
|
||
rightContent?: ReactNode;
|
||
variant?: TabBarVariant;
|
||
icons?: string[];
|
||
/** home 变体的字号,默认 18(HomeScreen 可传 20) */
|
||
fontSize?: number;
|
||
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,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.divider,
|
||
alignItems: 'center',
|
||
paddingRight: spacing.xs,
|
||
},
|
||
modernTab: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: spacing.md,
|
||
position: 'relative',
|
||
backgroundColor: 'transparent',
|
||
},
|
||
modernTabActive: {
|
||
backgroundColor: 'transparent',
|
||
},
|
||
modernTabContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
modernTabIcon: {
|
||
marginRight: spacing.xs,
|
||
},
|
||
modernTabText: {
|
||
fontWeight: '500',
|
||
fontSize: fontSizes.md,
|
||
},
|
||
modernTabTextActive: {
|
||
fontWeight: '600',
|
||
},
|
||
modernTabIndicator: {
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
left: '25%',
|
||
right: '25%',
|
||
height: 3,
|
||
backgroundColor: colors.primary.main,
|
||
borderTopLeftRadius: borderRadius.sm,
|
||
borderTopRightRadius: borderRadius.sm,
|
||
},
|
||
// home 变体:与 HomeScreen/SearchScreen 一致的文字下划线风格
|
||
homeContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: 'transparent',
|
||
},
|
||
homeTab: {
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: spacing.sm,
|
||
marginRight: 20,
|
||
borderBottomWidth: 2,
|
||
borderBottomColor: 'transparent',
|
||
},
|
||
homeTabActive: {
|
||
borderBottomColor: colors.text.primary,
|
||
},
|
||
homeTabText: {
|
||
fontSize: fontSizes.xl,
|
||
fontWeight: '400',
|
||
color: colors.text.hint,
|
||
},
|
||
homeTabTextActive: {
|
||
fontSize: fontSizes.xl,
|
||
fontWeight: '700',
|
||
color: colors.text.primary,
|
||
},
|
||
});
|
||
}
|
||
|
||
const TabBar: React.FC<TabBarProps> = ({
|
||
tabs,
|
||
activeIndex,
|
||
onTabChange,
|
||
scrollable = false,
|
||
rightContent,
|
||
variant = 'default',
|
||
icons,
|
||
fontSize,
|
||
style,
|
||
}) => {
|
||
const colors = useAppColors();
|
||
const styles = useMemo(() => createTabBarStyles(colors), [colors]);
|
||
|
||
const renderTabs = () => {
|
||
return tabs.map((tab, index) => {
|
||
const isActive = index === activeIndex;
|
||
const icon = icons?.[index];
|
||
|
||
if (variant === 'modern') {
|
||
return (
|
||
<TouchableOpacity
|
||
key={index}
|
||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||
onPress={() => onTabChange(index)}
|
||
activeOpacity={0.7}
|
||
>
|
||
<View style={styles.modernTabContent}>
|
||
{icon && (
|
||
<MaterialCommunityIcons
|
||
name={icon as any}
|
||
size={18}
|
||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||
style={styles.modernTabIcon}
|
||
/>
|
||
)}
|
||
<Text
|
||
variant="body"
|
||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||
style={isActive ? [styles.modernTabText, styles.modernTabTextActive] : styles.modernTabText}
|
||
>
|
||
{tab}
|
||
</Text>
|
||
</View>
|
||
{isActive && <View style={styles.modernTabIndicator} />}
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
if (variant === 'pill') {
|
||
return (
|
||
<TouchableOpacity
|
||
key={index}
|
||
style={[styles.pillTab, isActive && styles.pillTabActive]}
|
||
onPress={() => onTabChange(index)}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Text
|
||
variant="body"
|
||
color={isActive ? colors.text.inverse : colors.text.secondary}
|
||
style={styles.pillTabText}
|
||
>
|
||
{tab}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
if (variant === 'segmented') {
|
||
const isFirst = index === 0;
|
||
const isLast = index === tabs.length - 1;
|
||
return (
|
||
<TouchableOpacity
|
||
key={index}
|
||
style={[
|
||
styles.segmentedTab,
|
||
isActive && styles.segmentedTabActive,
|
||
isFirst && styles.segmentedTabFirst,
|
||
isLast && styles.segmentedTabLast,
|
||
]}
|
||
onPress={() => onTabChange(index)}
|
||
activeOpacity={0.8}
|
||
>
|
||
<View style={styles.segmentedTabContent}>
|
||
{icon && (
|
||
<MaterialCommunityIcons
|
||
name={icon as any}
|
||
size={16}
|
||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||
style={styles.segmentedTabIcon}
|
||
/>
|
||
)}
|
||
<Text variant="body" color={isActive ? colors.primary.main : colors.text.secondary} style={styles.segmentedTabText}>
|
||
{tab}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
if (variant === 'home') {
|
||
const homeTextStyle = [
|
||
isActive ? styles.homeTabTextActive : styles.homeTabText,
|
||
fontSize != null ? { fontSize } : undefined,
|
||
];
|
||
return (
|
||
<TouchableOpacity
|
||
key={index}
|
||
style={[styles.homeTab, isActive && styles.homeTabActive]}
|
||
onPress={() => onTabChange(index)}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text
|
||
variant="body"
|
||
color={isActive ? colors.text.primary : colors.text.hint}
|
||
style={homeTextStyle}
|
||
>
|
||
{tab}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
key={index}
|
||
style={[styles.tab, isActive && styles.activeTab]}
|
||
onPress={() => onTabChange(index)}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text
|
||
variant="body"
|
||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||
style={isActive ? [styles.tabText, styles.activeTabText] : styles.tabText}
|
||
>
|
||
{tab}
|
||
</Text>
|
||
{isActive && <View style={styles.activeIndicator} />}
|
||
</TouchableOpacity>
|
||
);
|
||
});
|
||
};
|
||
|
||
const getContainerStyle = () => {
|
||
switch (variant) {
|
||
case 'pill':
|
||
return styles.pillContainer;
|
||
case 'segmented':
|
||
return styles.segmentedContainer;
|
||
case 'modern':
|
||
return styles.modernContainer;
|
||
case 'home':
|
||
return styles.homeContainer;
|
||
default:
|
||
return styles.container;
|
||
}
|
||
};
|
||
|
||
if (scrollable) {
|
||
return (
|
||
<View style={[getContainerStyle(), style]}>
|
||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollableContainer}>
|
||
{renderTabs()}
|
||
</ScrollView>
|
||
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={[getContainerStyle(), style]}>
|
||
{renderTabs()}
|
||
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
||
</View>
|
||
);
|
||
};
|
||
|
||
export default React.memo(TabBar);
|