feat : Avatar avec Stack et résumé

This commit is contained in:
Yaël Perret
2026-09-10 18:41:23 +02:00
parent b6d6c02d0a
commit 4685e39985
5 changed files with 558 additions and 1 deletions

View File

@@ -0,0 +1,232 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { User } from 'lucide-react-native';
import { useEffect, useMemo, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { colors, gradientDirection, gradients } from '@/theme/colors';
import { fontFamily } from '@/theme/typography';
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export const AVATAR_SIZES: Record<AvatarSize, number> = {
xs: 20,
sm: 26,
md: 34,
lg: 48,
xl: 72,
};
export type AvatarProps = {
source?: ImageSourcePropType | string | null;
name?: string;
size?: AvatarSize;
shape?: 'circle' | 'rounded';
ring?: 'none' | 'border' | 'gradient';
ringColor?: string;
badge?: 'none' | 'online' | 'live';
onPress?: () => void;
style?: StyleProp<ViewStyle>;
};
function getInitials(name?: string): string {
if (!name) {
return '';
}
const words = name
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (words.length === 0) {
return '';
}
return words
.map((word) => word.charAt(0))
.join('')
.toUpperCase();
}
export function Avatar({
source,
name,
size = 'md',
shape = 'circle',
ring = 'none',
ringColor,
badge = 'none',
onPress,
style,
}: AvatarProps) {
const [hasImageError, setHasImageError] = useState(false);
const diameter = AVATAR_SIZES[size];
const radius = shape === 'rounded' ? Math.round(diameter * 0.3) : diameter / 2;
const innerRadius = ring === 'gradient' ? Math.max(0, radius - 2) : radius;
const fallbackInitials = useMemo(() => getInitials(name), [name]);
const normalizedSource =
typeof source === 'string' ? { uri: source } : (source ?? null);
useEffect(() => {
setHasImageError(false);
}, [source]);
const shouldShowImage = normalizedSource !== null && !hasImageError;
const badgeSize = Math.max(8, Math.round(diameter * 0.26));
const fallbackTextSize = Math.round(diameter * 0.38);
const iconSize = Math.round(diameter * 0.5);
const minTouchSize = 44;
const hitSlopPadding = diameter < minTouchSize ? Math.ceil((minTouchSize - diameter) / 2) : 0;
const frameStyle: ViewStyle = {
width: diameter,
height: diameter,
borderRadius: radius,
};
const innerStyle: ViewStyle = {
flex: 1,
borderRadius: innerRadius,
overflow: 'hidden',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
};
const borderStyle: ViewStyle =
ring === 'border'
? {
borderWidth: 2,
borderColor: ringColor ?? colors.border,
}
: {};
const badgeColor = badge === 'online' ? colors.success : colors.primary;
const innerContentNode = shouldShowImage ? (
<Image
source={normalizedSource}
style={styles.media}
contentFit="cover"
transition={180}
onError={() => setHasImageError(true)}
/>
) : fallbackInitials ? (
<Text
style={{
fontFamily: fontFamily.bodySemi,
fontSize: fallbackTextSize,
color: colors.textPrimary,
lineHeight: fallbackTextSize,
}}
>
{fallbackInitials}
</Text>
) : (
<User size={iconSize} color={colors.textSecondary} strokeWidth={1.9} />
);
const avatarNode =
ring === 'gradient' ? (
<LinearGradient
colors={gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={[frameStyle, styles.gradientFrame]}
>
<View style={innerStyle}>{innerContentNode}</View>
{badge !== 'none' ? (
<View
style={[
styles.badge,
{
width: badgeSize,
height: badgeSize,
borderRadius: badgeSize / 2,
backgroundColor: badgeColor,
},
]}
/>
) : null}
</LinearGradient>
) : (
<View style={[frameStyle, styles.frame, borderStyle]}>
<View style={innerStyle}>{innerContentNode}</View>
{badge !== 'none' ? (
<View
style={[
styles.badge,
{
width: badgeSize,
height: badgeSize,
borderRadius: badgeSize / 2,
backgroundColor: badgeColor,
},
]}
/>
) : null}
</View>
);
if (!onPress) {
return (
<View style={style} accessible={false}>
{avatarNode}
</View>
);
}
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={name ? `Avatar de ${name}` : 'Avatar'}
hitSlop={
hitSlopPadding > 0
? {
top: hitSlopPadding,
right: hitSlopPadding,
bottom: hitSlopPadding,
left: hitSlopPadding,
}
: undefined
}
style={style}
>
{avatarNode}
</Pressable>
);
}
const styles = StyleSheet.create({
media: {
width: '100%',
height: '100%',
},
gradientFrame: {
padding: 2,
},
frame: {
backgroundColor: colors.surfaceElevated,
},
badge: {
position: 'absolute',
right: 0,
bottom: 0,
borderWidth: 2,
borderColor: colors.surface,
},
});
export default Avatar;