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';