Files
frontend/src/components/business/TabBar.tsx
lafay 705b365536
Some checks failed
Frontend CI / ota (android) (push) Successful in 3m11s
Frontend CI / ota (ios) (push) Successful in 3m31s
Frontend CI / build-and-push-web (push) Successful in 4m38s
Frontend CI / build-android-apk (push) Has been cancelled
refactor(core): distinguish network errors from auth failures and refactor real-time messaging pipeline
- 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
2026-06-21 17:17:15 +08:00

389 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 变体的字号,默认 18HomeScreen 可传 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);