CI / quality (push) Waiting to run
- Add src/core/ (config, errors, events, http, observability, storage, query) - Add src/Spreadsheet/ engine with native POI backend and Excel bridge - Add Envelope C spreadsheet flow and tender bidding envelope modules - Consolidate components into src/components/, remove legacy Component/ and Hooks/ - Add registration state/validation, auth session guard, profile edit modules - Add unit tests across axios, redux, bidding, registration, encryption, core - Add CI workflow, docs (architecture, audit, DSC API spec), keystore example - Remove tracked keystore binary; ignore scripts.zip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
267 lines
9.1 KiB
TypeScript
267 lines
9.1 KiB
TypeScript
import React, { useEffect } from 'react';
|
|
import {
|
|
View,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
Dimensions,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
import * as LucideIcons from 'lucide-react-native';
|
|
import Animated, {
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withSpring,
|
|
withTiming,
|
|
interpolate,
|
|
Extrapolation
|
|
} from 'react-native-reanimated';
|
|
import Screens from '../Screens';
|
|
|
|
import NavigationString from '../Constant/NavigationString';
|
|
import { useTheme } from '../Hooks/useTheme';
|
|
import { typography } from '../theme/typography';
|
|
// import EditableExcel from '../Screens/Tender/Component/EnvelopeCExcelPreview';
|
|
|
|
const Tab = createBottomTabNavigator();
|
|
|
|
const TabButton = ({ route, isFocused, onPress }: any) => {
|
|
const { colors } = useTheme();
|
|
const icons: Record<string, { outline: keyof typeof LucideIcons; filled: keyof typeof LucideIcons }> = {
|
|
Home: { outline: "House", filled: "House" },
|
|
Auction: { outline: "Gavel", filled: "Gavel" },
|
|
Tender: { outline: "FileText", filled: "FileText" },
|
|
Profile: { outline: "User", filled: "User" },
|
|
|
|
};
|
|
// Lucide icons don't always have 'filled' variants in the set, so we might use the same icon
|
|
// or rely on color/strokeWidth to indicate state.
|
|
const iconKey = icons[route.name]?.outline || "HelpCircle"; // Fallback
|
|
const IconName = LucideIcons[iconKey] as React.ElementType;
|
|
|
|
const scale = useSharedValue(0);
|
|
const translateY = useSharedValue(0);
|
|
|
|
useEffect(() => {
|
|
if (isFocused) {
|
|
scale.value = withSpring(1, { damping: 12, stiffness: 100 });
|
|
translateY.value = withSpring(-4, { damping: 12, stiffness: 100 });
|
|
} else {
|
|
scale.value = withTiming(0, { duration: 200 });
|
|
translateY.value = withSpring(0, { damping: 12, stiffness: 100 });
|
|
}
|
|
// Intentionally runs only when isFocused changes; other deps are stable or recreated each render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isFocused]);
|
|
|
|
useAnimatedStyle(() => {
|
|
return {
|
|
transform: [{ scale: scale.value }],
|
|
opacity: interpolate(scale.value, [0, 1], [0, 1], Extrapolation.CLAMP),
|
|
};
|
|
});
|
|
|
|
const animatedIconStyle = useAnimatedStyle(() => {
|
|
return {
|
|
transform: [{ scale: interpolate(scale.value, [0, 1], [1, 0.9]) }], // Slight shrink on press
|
|
};
|
|
});
|
|
|
|
const animatedTextStyle = useAnimatedStyle(() => {
|
|
const opacity = interpolate(scale.value, [0, 1], [0, 1]);
|
|
const translateX = interpolate(scale.value, [0, 1], [20, 0]); // Slide in from right
|
|
return {
|
|
opacity,
|
|
transform: [{ translateX }],
|
|
display: scale.value === 0 ? 'none' : 'flex', // Hide when inactive
|
|
marginLeft: 4, // Reduced space
|
|
};
|
|
});
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
style={styles.tabButton}
|
|
activeOpacity={0.8}
|
|
accessibilityRole="tab"
|
|
accessibilityState={{ selected: isFocused }}
|
|
accessibilityLabel={`${route.name} tab`}
|
|
>
|
|
<View style={styles.tabContent}>
|
|
<Animated.View style={animatedIconStyle}>
|
|
{IconName && (
|
|
<IconName
|
|
size={24}
|
|
color={isFocused ? colors.primary : colors.text}
|
|
strokeWidth={isFocused ? 2.5 : 2}
|
|
/>
|
|
)}
|
|
</Animated.View>
|
|
|
|
{isFocused && (
|
|
<Animated.Text
|
|
style={[
|
|
styles.label,
|
|
animatedTextStyle,
|
|
{
|
|
color: colors.primary,
|
|
fontFamily: typography.fonts.medium,
|
|
fontWeight: undefined, // Remove fontWeight as we use fontFamily
|
|
},
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{route.name === 'BrandDay' ? 'Coupons' : route.name}
|
|
</Animated.Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
const CustomTabBar = ({ state, navigation }: any) => {
|
|
const { colors } = useTheme();
|
|
const { width: windowWidth } = Dimensions.get('window');
|
|
const TAB_BAR_WIDTH = windowWidth - 40; // 20 margin each side
|
|
const TAB_PADDING_H = 10; // 5 padding left + 5 padding right
|
|
const TAB_WIDTH = (TAB_BAR_WIDTH - TAB_PADDING_H) / state.routes.length;
|
|
|
|
const translateX = useSharedValue(0);
|
|
|
|
useEffect(() => {
|
|
translateX.value = withSpring(state.index * TAB_WIDTH, {
|
|
damping: 15,
|
|
stiffness: 150,
|
|
mass: 0.5,
|
|
});
|
|
// Intentionally runs only when the selected tab index changes; other deps are stable or recreated each render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state.index]);
|
|
|
|
const animatedIndicatorStyle = useAnimatedStyle(() => {
|
|
return {
|
|
transform: [{ translateX: translateX.value }],
|
|
};
|
|
});
|
|
|
|
return (
|
|
<View style={[
|
|
styles.tabBar,
|
|
{
|
|
backgroundColor: colors.background, // Use transparent to allow shadow to show
|
|
shadowColor: colors.shadow, // Dynamic shadow color
|
|
shadowOffset: { width: 0, height: 10 },
|
|
shadowOpacity: 0.2, // Consistent opacity
|
|
shadowRadius: 10,
|
|
borderColor: colors.border, // Subtle border for definition
|
|
borderWidth: 3,
|
|
elevation: 20,
|
|
}
|
|
]}>
|
|
<Animated.View
|
|
style={[
|
|
styles.slidingIndicator,
|
|
{
|
|
width: TAB_WIDTH,
|
|
backgroundColor: colors.primary,
|
|
opacity: 0.3,
|
|
elevation: 10, // Subtle elevation for the indicator
|
|
},
|
|
animatedIndicatorStyle,
|
|
]}
|
|
/>
|
|
{state.routes.map((route: any, index: number) => {
|
|
const isFocused = state.index === index;
|
|
|
|
return (
|
|
<TabButton
|
|
key={route.key}
|
|
route={route}
|
|
isFocused={isFocused}
|
|
onPress={() => navigation.navigate(route.name)}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default function BottomTab() {
|
|
const { colors } = useTheme();
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
|
<Tab.Navigator
|
|
tabBar={(props) => <CustomTabBar {...props} />}
|
|
screenOptions={{
|
|
headerShown: false,
|
|
tabBarHideOnKeyboard: true,
|
|
// Smooth cross-fade when switching tabs instead of an instant cut.
|
|
animation: 'shift',
|
|
}}
|
|
>
|
|
<Tab.Screen name="Home" component={Screens.Home} />
|
|
<Tab.Screen name={NavigationString.Auction} component={Screens.Auction} />
|
|
<Tab.Screen name={NavigationString.Tender} component={Screens.Tender} />
|
|
{/* <Tab.Screen name={'EditableExcel'} component={EditableExcel} /> */}
|
|
<Tab.Screen name={NavigationString.Profile} component={Screens.ProfileTab} />
|
|
|
|
</Tab.Navigator>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
|
|
},
|
|
tabBar: {
|
|
position: 'absolute',
|
|
bottom: 20,
|
|
left: 20,
|
|
right: 20,
|
|
flexDirection: 'row',
|
|
height: 70,
|
|
borderRadius: 35,
|
|
elevation: 20, // stronger Android elevation
|
|
shadowColor: '#000', // iOS shadow color
|
|
shadowOffset: { width: 0, height: 10 },
|
|
shadowOpacity: Platform.OS === 'ios' ? 0.25 : 0.2,
|
|
shadowRadius: 12,
|
|
overflow: 'visible', // allow shadow to render outside rounded corners
|
|
zIndex: 9999,
|
|
paddingBottom: 0,
|
|
justifyContent: 'space-around',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 5,
|
|
},
|
|
tabButton: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
height: '100%',
|
|
},
|
|
tabContent: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 4, // Drastically reduced from 12
|
|
paddingVertical: 4,
|
|
borderRadius: 20,
|
|
},
|
|
slidingIndicator: {
|
|
position: 'absolute',
|
|
top: 12,
|
|
left: 5,
|
|
height: 40,
|
|
borderRadius: 30,
|
|
zIndex: 0,
|
|
},
|
|
label: {
|
|
fontSize: 11, // Slightly smaller text
|
|
textAlign: 'left',
|
|
textTransform: 'capitalize',
|
|
letterSpacing: 0.2, // Tighter spacing
|
|
},
|
|
});
|