diff --git a/src/app/switch-demo.tsx b/src/app/switch-demo.tsx
index 23c8d2e..0a7066a 100644
--- a/src/app/switch-demo.tsx
+++ b/src/app/switch-demo.tsx
@@ -3,7 +3,7 @@ import { StyleSheet, Text, View } from 'react-native';
import { Button } from '@/components/Button';
import { FormField, Switch } from '@/components/form';
-import { RangeSlider, Screen, Slider } from '@/components/ui';
+import { AttendeeSummary, Avatar, AvatarStack, RangeSlider, Screen, Slider } from '@/components/ui';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
@@ -17,6 +17,38 @@ export default function SwitchDemoScreen() {
const [resultCount, setResultCount] = useState(128);
const [priceRange, setPriceRange] = useState<[number, number]>([0, 65]);
+ const attendees = [
+ {
+ id: 'lea',
+ source:
+ 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=240&q=80',
+ name: 'Lea Martin',
+ },
+ {
+ id: 'yanis',
+ source:
+ 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=240&q=80',
+ name: 'Yanis Belaid',
+ },
+ {
+ id: 'omar',
+ source:
+ 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=240&q=80',
+ name: 'Omar Khelifi',
+ },
+ ];
+
+ const attendeesWithFallbacks = [
+ { id: 'nora', source: null, name: 'Nora Salem' },
+ { id: 'idris', source: null, name: 'Idris Khan' },
+ {
+ id: 'mila',
+ source:
+ 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&w=240&q=80',
+ name: 'Mila Chen',
+ },
+ ];
+
const formatKm = (v: number) => `${v} km`;
const formatChf = (v: number) => `${v} CHF`;
const formatPriceRange = (minValue: number, maxValue: number) => {
@@ -175,6 +207,57 @@ export default function SwitchDemoScreen() {
showBounds
/>
+
+
+
+
+
+
+ 1) B1 · Stack 3 sur 12 (+9)
+
+
+
+
+ 2) B1 · Meme stack sans bulle
+
+
+
+
+ 3) C3 · Avatar XL gradient + live
+
+
+
+
+ 4) Summary 0 puis 142 dont 3 amis
+
+
+
+
+
+
+
+ 5) Stack avec fallback initiales
+
+
+
+
);
}
@@ -243,4 +326,17 @@ const styles = StyleSheet.create({
backgroundColor: colors.divider,
marginVertical: spacing[4],
},
+ avatarStack: {
+ gap: spacing[12],
+ },
+ exampleRow: {
+ gap: spacing[8],
+ },
+ exampleLabel: {
+ ...typography.metadata,
+ color: colors.textSecondary,
+ },
+ summaryStack: {
+ gap: spacing[8],
+ },
});
diff --git a/src/components/ui/AttendeeSummary.tsx b/src/components/ui/AttendeeSummary.tsx
new file mode 100644
index 0000000..45c15a4
--- /dev/null
+++ b/src/components/ui/AttendeeSummary.tsx
@@ -0,0 +1,85 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+
+import { colors } from '@/theme/colors';
+import { fontFamily, typography } from '@/theme/typography';
+
+import { AvatarStack, type AvatarStackProps } from './AvatarStack';
+
+export type AttendeeSummaryProps = Pick<
+ AvatarStackProps,
+ 'items' | 'max' | 'size' | 'ringColor'
+> & {
+ total: number;
+ formatLabel?: (n: number) => string;
+ highlight?: string;
+ onPress?: () => void;
+};
+
+function defaultFormatLabel(n: number): string {
+ if (n === 0) {
+ return 'Sois le premier';
+ }
+
+ if (n === 1) {
+ return '1 participant';
+ }
+
+ return `${n} participants`;
+}
+
+export function AttendeeSummary({
+ items,
+ max,
+ size,
+ ringColor,
+ total,
+ formatLabel = defaultFormatLabel,
+ highlight,
+ onPress,
+}: AttendeeSummaryProps) {
+ const label = formatLabel(total);
+ const accessibilityLabel = highlight ? `${label}, ${highlight}` : label;
+ const Root = onPress ? Pressable : View;
+
+ return (
+
+
+
+
+ {label}
+ {highlight ? {highlight} : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ label: {
+ ...typography.metadata,
+ color: colors.textSecondary,
+ flexShrink: 1,
+ },
+ highlight: {
+ color: colors.primary,
+ fontFamily: fontFamily.bodySemi,
+ },
+});
+
+export default AttendeeSummary;
\ No newline at end of file
diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx
new file mode 100644
index 0000000..829d828
--- /dev/null
+++ b/src/components/ui/Avatar.tsx
@@ -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 = {
+ 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;
+};
+
+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 ? (
+ setHasImageError(true)}
+ />
+ ) : fallbackInitials ? (
+
+ {fallbackInitials}
+
+ ) : (
+
+ );
+
+ const avatarNode =
+ ring === 'gradient' ? (
+
+ {innerContentNode}
+ {badge !== 'none' ? (
+
+ ) : null}
+
+ ) : (
+
+ {innerContentNode}
+ {badge !== 'none' ? (
+
+ ) : null}
+
+ );
+
+ if (!onPress) {
+ return (
+
+ {avatarNode}
+
+ );
+ }
+
+ return (
+ 0
+ ? {
+ top: hitSlopPadding,
+ right: hitSlopPadding,
+ bottom: hitSlopPadding,
+ left: hitSlopPadding,
+ }
+ : undefined
+ }
+ style={style}
+ >
+ {avatarNode}
+
+ );
+}
+
+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;
\ No newline at end of file
diff --git a/src/components/ui/AvatarStack.tsx b/src/components/ui/AvatarStack.tsx
new file mode 100644
index 0000000..6dfb430
--- /dev/null
+++ b/src/components/ui/AvatarStack.tsx
@@ -0,0 +1,138 @@
+import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
+
+import { colors } from '@/theme/colors';
+import { fontFamily } from '@/theme/typography';
+
+import { AVATAR_SIZES, Avatar, type AvatarSize } from './Avatar';
+
+export type AvatarStackItem = { id: string; source?: string | null; name?: string };
+
+export type AvatarStackProps = {
+ items: AvatarStackItem[];
+ max?: number;
+ size?: AvatarSize;
+ overlap?: number;
+ showOverflow?: boolean;
+ total?: number;
+ ring?: 'border' | 'none';
+ ringColor?: string;
+ onPress?: () => void;
+};
+
+function formatParticipantsLabel(count: number): string {
+ return count === 1 ? '1 participant' : `${count} participants`;
+}
+
+export function AvatarStack({
+ items,
+ max = 3,
+ size = 'sm',
+ overlap = 0.34,
+ showOverflow = true,
+ total,
+ ring = 'border',
+ ringColor,
+ onPress,
+}: AvatarStackProps) {
+ if (items.length === 0) {
+ return null;
+ }
+
+ const diameter = AVATAR_SIZES[size];
+ const visibleCount = Math.max(0, Math.min(max, items.length));
+ const visibleItems = items.slice(0, visibleCount);
+ const effectiveTotal = total ?? items.length;
+ const overflowCount = Math.max(0, effectiveTotal - visibleCount);
+ const borderColor = ringColor ?? colors.surface;
+
+ type StackSlot =
+ | { key: string; kind: 'overflow'; value: number }
+ | { key: string; kind: 'avatar'; item: AvatarStackItem };
+
+ const slots: StackSlot[] = [];
+
+ if (showOverflow !== false && overflowCount > 0) {
+ slots.push({ key: 'overflow', kind: 'overflow', value: overflowCount });
+ }
+
+ visibleItems
+ .slice()
+ .reverse()
+ .forEach((item) => {
+ slots.push({ key: item.id, kind: 'avatar', item });
+ });
+
+ const rootStyle: ViewStyle = {
+ flexDirection: 'row-reverse',
+ alignItems: 'center',
+ };
+
+ const StackRoot = onPress ? Pressable : View;
+
+ return (
+
+ {slots.map((slot, index) => {
+ const offsetStyle = index === 0 ? null : { marginLeft: -(diameter * overlap) };
+
+ if (slot.kind === 'overflow') {
+ return (
+
+
+ +{slot.value}
+
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overflow: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.surfaceElevated,
+ },
+});
+
+export default AvatarStack;
\ No newline at end of file
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index c5a8480..42bfb9f 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,8 +1,14 @@
+export { AttendeeSummary } from './AttendeeSummary';
+export { Avatar, AVATAR_SIZES } from './Avatar';
+export { AvatarStack } from './AvatarStack';
export { Header } from './Header';
export { RangeSlider } from './RangeSlider';
export { Screen } from './Screen';
export { Slider } from './Slider';
+export type { AttendeeSummaryProps } from './AttendeeSummary';
+export type { AvatarProps, AvatarSize } from './Avatar';
+export type { AvatarStackItem, AvatarStackProps } from './AvatarStack';
export type { RangeSliderProps } from './RangeSlider';
export type { SliderProps } from './Slider';