@croacroa/react-native-template 2.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.env.example +5 -0
  2. package/.eslintrc.js +8 -0
  3. package/.github/workflows/ci.yml +187 -187
  4. package/.github/workflows/eas-build.yml +55 -55
  5. package/.github/workflows/eas-update.yml +50 -50
  6. package/.github/workflows/npm-publish.yml +57 -0
  7. package/CHANGELOG.md +195 -106
  8. package/CONTRIBUTING.md +377 -377
  9. package/LICENSE +21 -0
  10. package/README.md +446 -399
  11. package/__tests__/accessibility/components.test.tsx +285 -0
  12. package/__tests__/components/Button.test.tsx +2 -4
  13. package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
  14. package/__tests__/components/snapshots.test.tsx +131 -131
  15. package/__tests__/helpers/a11y.ts +54 -0
  16. package/__tests__/hooks/useAnalytics.test.ts +100 -0
  17. package/__tests__/hooks/useAnimations.test.ts +70 -0
  18. package/__tests__/hooks/useAuth.test.tsx +71 -28
  19. package/__tests__/hooks/useMedia.test.ts +318 -0
  20. package/__tests__/hooks/usePayments.test.tsx +307 -0
  21. package/__tests__/hooks/usePermission.test.ts +230 -0
  22. package/__tests__/hooks/useWebSocket.test.ts +329 -0
  23. package/__tests__/integration/auth-api.test.tsx +224 -227
  24. package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
  25. package/__tests__/services/api.test.ts +24 -6
  26. package/app/(auth)/home.tsx +11 -9
  27. package/app/(auth)/profile.tsx +8 -6
  28. package/app/(auth)/settings.tsx +11 -9
  29. package/app/(public)/forgot-password.tsx +25 -15
  30. package/app/(public)/login.tsx +48 -12
  31. package/app/(public)/onboarding.tsx +5 -5
  32. package/app/(public)/register.tsx +24 -15
  33. package/app/_layout.tsx +6 -3
  34. package/app.config.ts +27 -2
  35. package/assets/images/.gitkeep +7 -7
  36. package/assets/images/adaptive-icon.png +0 -0
  37. package/assets/images/favicon.png +0 -0
  38. package/assets/images/icon.png +0 -0
  39. package/assets/images/notification-icon.png +0 -0
  40. package/assets/images/splash.png +0 -0
  41. package/components/ErrorBoundary.tsx +73 -28
  42. package/components/auth/SocialLoginButtons.tsx +168 -0
  43. package/components/forms/FormInput.tsx +5 -3
  44. package/components/onboarding/OnboardingScreen.tsx +370 -370
  45. package/components/onboarding/index.ts +2 -2
  46. package/components/providers/AnalyticsProvider.tsx +67 -0
  47. package/components/providers/SuspenseBoundary.tsx +359 -357
  48. package/components/providers/index.ts +24 -21
  49. package/components/ui/AnimatedButton.tsx +1 -9
  50. package/components/ui/AnimatedList.tsx +98 -0
  51. package/components/ui/AnimatedScreen.tsx +89 -0
  52. package/components/ui/Avatar.tsx +319 -316
  53. package/components/ui/Badge.tsx +416 -416
  54. package/components/ui/BottomSheet.tsx +307 -307
  55. package/components/ui/Button.tsx +11 -3
  56. package/components/ui/Checkbox.tsx +261 -261
  57. package/components/ui/FeatureGate.tsx +57 -0
  58. package/components/ui/ForceUpdateScreen.tsx +108 -0
  59. package/components/ui/ImagePickerButton.tsx +180 -0
  60. package/components/ui/Input.stories.tsx +2 -10
  61. package/components/ui/Input.tsx +2 -10
  62. package/components/ui/OptimizedImage.tsx +369 -369
  63. package/components/ui/Paywall.tsx +253 -0
  64. package/components/ui/PermissionGate.tsx +155 -0
  65. package/components/ui/PurchaseButton.tsx +84 -0
  66. package/components/ui/Select.tsx +240 -240
  67. package/components/ui/Skeleton.tsx +3 -1
  68. package/components/ui/Toast.tsx +427 -0
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -23
  72. package/constants/config.ts +135 -97
  73. package/docs/adr/001-state-management.md +79 -79
  74. package/docs/adr/002-styling-approach.md +130 -130
  75. package/docs/adr/003-data-fetching.md +155 -155
  76. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  77. package/docs/adr/README.md +78 -78
  78. package/docs/guides/analytics-posthog.md +121 -0
  79. package/docs/guides/auth-supabase.md +162 -0
  80. package/docs/guides/feature-flags-launchdarkly.md +150 -0
  81. package/docs/guides/payments-revenuecat.md +169 -0
  82. package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
  83. package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
  84. package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
  85. package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
  86. package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
  87. package/eas.json +2 -1
  88. package/hooks/index.ts +70 -27
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +64 -4
  91. package/hooks/useAuth.tsx +7 -3
  92. package/hooks/useBiometrics.ts +295 -295
  93. package/hooks/useChannel.ts +111 -0
  94. package/hooks/useDeepLinking.ts +256 -256
  95. package/hooks/useExperiment.ts +36 -0
  96. package/hooks/useFeatureFlag.ts +59 -0
  97. package/hooks/useForceUpdate.ts +91 -0
  98. package/hooks/useImagePicker.ts +281 -0
  99. package/hooks/useInAppReview.ts +64 -0
  100. package/hooks/useMFA.ts +509 -499
  101. package/hooks/useParallax.ts +142 -0
  102. package/hooks/usePerformance.ts +434 -434
  103. package/hooks/usePermission.ts +190 -0
  104. package/hooks/usePresence.ts +129 -0
  105. package/hooks/useProducts.ts +36 -0
  106. package/hooks/usePurchase.ts +103 -0
  107. package/hooks/useRateLimit.ts +70 -0
  108. package/hooks/useSubscription.ts +49 -0
  109. package/hooks/useTrackEvent.ts +52 -0
  110. package/hooks/useTrackScreen.ts +40 -0
  111. package/hooks/useUpdates.ts +358 -358
  112. package/hooks/useUpload.ts +165 -0
  113. package/hooks/useWebSocket.ts +111 -0
  114. package/i18n/index.ts +197 -194
  115. package/i18n/locales/ar.json +170 -101
  116. package/i18n/locales/de.json +170 -101
  117. package/i18n/locales/en.json +170 -101
  118. package/i18n/locales/es.json +170 -101
  119. package/i18n/locales/fr.json +170 -101
  120. package/jest.config.js +1 -1
  121. package/maestro/README.md +113 -113
  122. package/maestro/config.yaml +35 -35
  123. package/maestro/flows/login.yaml +62 -62
  124. package/maestro/flows/mfa-login.yaml +92 -92
  125. package/maestro/flows/mfa-setup.yaml +86 -86
  126. package/maestro/flows/navigation.yaml +68 -68
  127. package/maestro/flows/offline-conflict.yaml +101 -101
  128. package/maestro/flows/offline-sync.yaml +128 -128
  129. package/maestro/flows/offline.yaml +60 -60
  130. package/maestro/flows/register.yaml +94 -94
  131. package/package.json +188 -175
  132. package/scripts/generate-placeholders.js +38 -0
  133. package/services/analytics/adapters/console.ts +50 -0
  134. package/services/analytics/analytics-adapter.ts +94 -0
  135. package/services/analytics/types.ts +73 -0
  136. package/services/analytics.ts +428 -428
  137. package/services/api.ts +419 -340
  138. package/services/auth/social/apple.ts +110 -0
  139. package/services/auth/social/google.ts +159 -0
  140. package/services/auth/social/social-auth.ts +100 -0
  141. package/services/auth/social/types.ts +80 -0
  142. package/services/authAdapter.ts +333 -333
  143. package/services/backgroundSync.ts +652 -626
  144. package/services/feature-flags/adapters/mock.ts +108 -0
  145. package/services/feature-flags/feature-flag-adapter.ts +174 -0
  146. package/services/feature-flags/types.ts +79 -0
  147. package/services/force-update.ts +140 -0
  148. package/services/index.ts +116 -54
  149. package/services/media/compression.ts +91 -0
  150. package/services/media/media-picker.ts +151 -0
  151. package/services/media/media-upload.ts +160 -0
  152. package/services/payments/adapters/mock.ts +159 -0
  153. package/services/payments/payment-adapter.ts +118 -0
  154. package/services/payments/types.ts +131 -0
  155. package/services/permissions/permission-manager.ts +284 -0
  156. package/services/permissions/types.ts +104 -0
  157. package/services/realtime/types.ts +100 -0
  158. package/services/realtime/websocket-manager.ts +441 -0
  159. package/services/security.ts +289 -286
  160. package/services/sentry.ts +4 -4
  161. package/stores/appStore.ts +9 -0
  162. package/stores/notificationStore.ts +3 -1
  163. package/tailwind.config.js +47 -47
  164. package/tsconfig.json +37 -13
  165. package/types/user.ts +1 -1
  166. package/utils/accessibility.ts +446 -446
  167. package/utils/animations/presets.ts +182 -0
  168. package/utils/animations/transitions.ts +62 -0
  169. package/utils/index.ts +63 -52
  170. package/utils/toast.ts +9 -2
  171. package/utils/validation.ts +4 -1
  172. package/utils/withAccessibility.tsx +272 -272
@@ -1,316 +1,319 @@
1
- import { View, Text } from "react-native";
2
- import { Image } from "expo-image";
3
- import { Ionicons } from "@expo/vector-icons";
4
- import { useTheme } from "@/hooks/useTheme";
5
- import { cn } from "@/utils/cn";
6
-
7
- type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
8
-
9
- interface AvatarProps {
10
- /**
11
- * Image source URL
12
- */
13
- source?: string | null;
14
-
15
- /**
16
- * User's name (used for initials fallback)
17
- */
18
- name?: string;
19
-
20
- /**
21
- * Size variant
22
- */
23
- size?: AvatarSize;
24
-
25
- /**
26
- * Custom size in pixels (overrides size variant)
27
- */
28
- customSize?: number;
29
-
30
- /**
31
- * Whether to show online indicator
32
- */
33
- showOnlineIndicator?: boolean;
34
-
35
- /**
36
- * Whether the user is online
37
- */
38
- isOnline?: boolean;
39
-
40
- /**
41
- * Additional class name
42
- */
43
- className?: string;
44
-
45
- /**
46
- * Border color class
47
- */
48
- borderClassName?: string;
49
- }
50
-
51
- const sizeConfig: Record<
52
- AvatarSize,
53
- { container: number; text: string; icon: number; indicator: number }
54
- > = {
55
- xs: { container: 24, text: "text-xs", icon: 12, indicator: 6 },
56
- sm: { container: 32, text: "text-sm", icon: 16, indicator: 8 },
57
- md: { container: 40, text: "text-base", icon: 20, indicator: 10 },
58
- lg: { container: 48, text: "text-lg", icon: 24, indicator: 12 },
59
- xl: { container: 64, text: "text-xl", icon: 32, indicator: 14 },
60
- "2xl": { container: 80, text: "text-2xl", icon: 40, indicator: 16 },
61
- };
62
-
63
- /**
64
- * Get initials from a name
65
- */
66
- function getInitials(name: string): string {
67
- const parts = name.trim().split(/\s+/);
68
- if (parts.length === 1) {
69
- return parts[0].substring(0, 2).toUpperCase();
70
- }
71
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
72
- }
73
-
74
- /**
75
- * Get a consistent color based on the name
76
- */
77
- function getAvatarColor(name: string): string {
78
- const colors = [
79
- "#ef4444", // red
80
- "#f97316", // orange
81
- "#f59e0b", // amber
82
- "#84cc16", // lime
83
- "#10b981", // emerald
84
- "#14b8a6", // teal
85
- "#06b6d4", // cyan
86
- "#0ea5e9", // sky
87
- "#3b82f6", // blue
88
- "#6366f1", // indigo
89
- "#8b5cf6", // violet
90
- "#a855f7", // purple
91
- "#d946ef", // fuchsia
92
- "#ec4899", // pink
93
- "#f43f5e", // rose
94
- ];
95
-
96
- let hash = 0;
97
- for (let i = 0; i < name.length; i++) {
98
- hash = name.charCodeAt(i) + ((hash << 5) - hash);
99
- }
100
-
101
- return colors[Math.abs(hash) % colors.length];
102
- }
103
-
104
- export function Avatar({
105
- source,
106
- name,
107
- size = "md",
108
- customSize,
109
- showOnlineIndicator = false,
110
- isOnline = false,
111
- className,
112
- borderClassName,
113
- }: AvatarProps) {
114
- const { isDark } = useTheme();
115
- const config = sizeConfig[size];
116
- const containerSize = customSize || config.container;
117
-
118
- const initials = name ? getInitials(name) : "";
119
- const backgroundColor = name
120
- ? getAvatarColor(name)
121
- : isDark
122
- ? "#475569"
123
- : "#cbd5e1";
124
-
125
- const renderContent = () => {
126
- // Image avatar
127
- if (source) {
128
- return (
129
- <Image
130
- source={{ uri: source }}
131
- style={{
132
- width: containerSize,
133
- height: containerSize,
134
- borderRadius: containerSize / 2,
135
- }}
136
- contentFit="cover"
137
- transition={200}
138
- placeholder={require("@/assets/images/icon.png")}
139
- />
140
- );
141
- }
142
-
143
- // Initials avatar
144
- if (name) {
145
- return (
146
- <View
147
- style={{
148
- width: containerSize,
149
- height: containerSize,
150
- borderRadius: containerSize / 2,
151
- backgroundColor,
152
- }}
153
- className="items-center justify-center"
154
- >
155
- <Text
156
- className={cn(config.text, "font-semibold text-white")}
157
- style={{
158
- fontSize: customSize ? customSize * 0.4 : undefined,
159
- }}
160
- >
161
- {initials}
162
- </Text>
163
- </View>
164
- );
165
- }
166
-
167
- // Default placeholder
168
- return (
169
- <View
170
- style={{
171
- width: containerSize,
172
- height: containerSize,
173
- borderRadius: containerSize / 2,
174
- }}
175
- className={cn(
176
- "items-center justify-center",
177
- isDark ? "bg-gray-700" : "bg-gray-200"
178
- )}
179
- >
180
- <Ionicons
181
- name="person"
182
- size={customSize ? customSize * 0.5 : config.icon}
183
- color={isDark ? "#94a3b8" : "#64748b"}
184
- />
185
- </View>
186
- );
187
- };
188
-
189
- return (
190
- <View
191
- className={cn("relative", className)}
192
- style={{ width: containerSize, height: containerSize }}
193
- >
194
- <View
195
- className={cn("overflow-hidden rounded-full", borderClassName)}
196
- style={{
197
- width: containerSize,
198
- height: containerSize,
199
- borderRadius: containerSize / 2,
200
- }}
201
- >
202
- {renderContent()}
203
- </View>
204
-
205
- {/* Online indicator */}
206
- {showOnlineIndicator && (
207
- <View
208
- style={{
209
- width: config.indicator,
210
- height: config.indicator,
211
- borderRadius: config.indicator / 2,
212
- borderWidth: 2,
213
- position: "absolute",
214
- bottom: 0,
215
- right: 0,
216
- }}
217
- className={cn(
218
- isOnline ? "bg-green-500" : "bg-gray-400",
219
- isDark ? "border-background-dark" : "border-white"
220
- )}
221
- />
222
- )}
223
- </View>
224
- );
225
- }
226
-
227
- /**
228
- * Avatar Group component for displaying multiple avatars
229
- */
230
- interface AvatarGroupProps {
231
- /**
232
- * Array of avatar data
233
- */
234
- avatars: {
235
- source?: string | null;
236
- name?: string;
237
- }[];
238
-
239
- /**
240
- * Maximum number of avatars to display
241
- */
242
- max?: number;
243
-
244
- /**
245
- * Size variant
246
- */
247
- size?: AvatarSize;
248
-
249
- /**
250
- * Additional class name
251
- */
252
- className?: string;
253
- }
254
-
255
- export function AvatarGroup({
256
- avatars,
257
- max = 4,
258
- size = "md",
259
- className,
260
- }: AvatarGroupProps) {
261
- const { isDark } = useTheme();
262
- const config = sizeConfig[size];
263
- const displayAvatars = avatars.slice(0, max);
264
- const remainingCount = avatars.length - max;
265
-
266
- return (
267
- <View className={cn("flex-row items-center", className)}>
268
- {displayAvatars.map((avatar, index) => (
269
- <View
270
- key={index}
271
- style={{
272
- marginLeft: index > 0 ? -config.container / 3 : 0,
273
- zIndex: displayAvatars.length - index,
274
- }}
275
- >
276
- <Avatar
277
- source={avatar.source}
278
- name={avatar.name}
279
- size={size}
280
- borderClassName={cn(
281
- "border-2",
282
- isDark ? "border-background-dark" : "border-white"
283
- )}
284
- />
285
- </View>
286
- ))}
287
-
288
- {remainingCount > 0 && (
289
- <View
290
- style={{
291
- marginLeft: -config.container / 3,
292
- width: config.container,
293
- height: config.container,
294
- borderRadius: config.container / 2,
295
- }}
296
- className={cn(
297
- "items-center justify-center border-2",
298
- isDark
299
- ? "bg-gray-700 border-background-dark"
300
- : "bg-gray-200 border-white"
301
- )}
302
- >
303
- <Text
304
- className={cn(
305
- config.text,
306
- "font-medium",
307
- isDark ? "text-text-dark" : "text-text-light"
308
- )}
309
- >
310
- +{remainingCount}
311
- </Text>
312
- </View>
313
- )}
314
- </View>
315
- );
316
- }
1
+ import { View, Text } from "react-native";
2
+ import { Image } from "expo-image";
3
+ import { Ionicons } from "@expo/vector-icons";
4
+ import { useTheme } from "@/hooks/useTheme";
5
+ import { cn } from "@/utils/cn";
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
8
+ const avatarPlaceholder = require("@/assets/images/icon.png");
9
+
10
+ type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
11
+
12
+ interface AvatarProps {
13
+ /**
14
+ * Image source URL
15
+ */
16
+ source?: string | null;
17
+
18
+ /**
19
+ * User's name (used for initials fallback)
20
+ */
21
+ name?: string;
22
+
23
+ /**
24
+ * Size variant
25
+ */
26
+ size?: AvatarSize;
27
+
28
+ /**
29
+ * Custom size in pixels (overrides size variant)
30
+ */
31
+ customSize?: number;
32
+
33
+ /**
34
+ * Whether to show online indicator
35
+ */
36
+ showOnlineIndicator?: boolean;
37
+
38
+ /**
39
+ * Whether the user is online
40
+ */
41
+ isOnline?: boolean;
42
+
43
+ /**
44
+ * Additional class name
45
+ */
46
+ className?: string;
47
+
48
+ /**
49
+ * Border color class
50
+ */
51
+ borderClassName?: string;
52
+ }
53
+
54
+ const sizeConfig: Record<
55
+ AvatarSize,
56
+ { container: number; text: string; icon: number; indicator: number }
57
+ > = {
58
+ xs: { container: 24, text: "text-xs", icon: 12, indicator: 6 },
59
+ sm: { container: 32, text: "text-sm", icon: 16, indicator: 8 },
60
+ md: { container: 40, text: "text-base", icon: 20, indicator: 10 },
61
+ lg: { container: 48, text: "text-lg", icon: 24, indicator: 12 },
62
+ xl: { container: 64, text: "text-xl", icon: 32, indicator: 14 },
63
+ "2xl": { container: 80, text: "text-2xl", icon: 40, indicator: 16 },
64
+ };
65
+
66
+ /**
67
+ * Get initials from a name
68
+ */
69
+ function getInitials(name: string): string {
70
+ const parts = name.trim().split(/\s+/);
71
+ if (parts.length === 1) {
72
+ return parts[0].substring(0, 2).toUpperCase();
73
+ }
74
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
75
+ }
76
+
77
+ /**
78
+ * Get a consistent color based on the name
79
+ */
80
+ function getAvatarColor(name: string): string {
81
+ const colors = [
82
+ "#ef4444", // red
83
+ "#f97316", // orange
84
+ "#f59e0b", // amber
85
+ "#84cc16", // lime
86
+ "#10b981", // emerald
87
+ "#14b8a6", // teal
88
+ "#06b6d4", // cyan
89
+ "#0ea5e9", // sky
90
+ "#3b82f6", // blue
91
+ "#6366f1", // indigo
92
+ "#8b5cf6", // violet
93
+ "#a855f7", // purple
94
+ "#d946ef", // fuchsia
95
+ "#ec4899", // pink
96
+ "#f43f5e", // rose
97
+ ];
98
+
99
+ let hash = 0;
100
+ for (let i = 0; i < name.length; i++) {
101
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
102
+ }
103
+
104
+ return colors[Math.abs(hash) % colors.length];
105
+ }
106
+
107
+ export function Avatar({
108
+ source,
109
+ name,
110
+ size = "md",
111
+ customSize,
112
+ showOnlineIndicator = false,
113
+ isOnline = false,
114
+ className,
115
+ borderClassName,
116
+ }: AvatarProps) {
117
+ const { isDark } = useTheme();
118
+ const config = sizeConfig[size];
119
+ const containerSize = customSize || config.container;
120
+
121
+ const initials = name ? getInitials(name) : "";
122
+ const backgroundColor = name
123
+ ? getAvatarColor(name)
124
+ : isDark
125
+ ? "#475569"
126
+ : "#cbd5e1";
127
+
128
+ const renderContent = () => {
129
+ // Image avatar
130
+ if (source) {
131
+ return (
132
+ <Image
133
+ source={{ uri: source }}
134
+ style={{
135
+ width: containerSize,
136
+ height: containerSize,
137
+ borderRadius: containerSize / 2,
138
+ }}
139
+ contentFit="cover"
140
+ transition={200}
141
+ placeholder={avatarPlaceholder}
142
+ />
143
+ );
144
+ }
145
+
146
+ // Initials avatar
147
+ if (name) {
148
+ return (
149
+ <View
150
+ style={{
151
+ width: containerSize,
152
+ height: containerSize,
153
+ borderRadius: containerSize / 2,
154
+ backgroundColor,
155
+ }}
156
+ className="items-center justify-center"
157
+ >
158
+ <Text
159
+ className={cn(config.text, "font-semibold text-white")}
160
+ style={{
161
+ fontSize: customSize ? customSize * 0.4 : undefined,
162
+ }}
163
+ >
164
+ {initials}
165
+ </Text>
166
+ </View>
167
+ );
168
+ }
169
+
170
+ // Default placeholder
171
+ return (
172
+ <View
173
+ style={{
174
+ width: containerSize,
175
+ height: containerSize,
176
+ borderRadius: containerSize / 2,
177
+ }}
178
+ className={cn(
179
+ "items-center justify-center",
180
+ isDark ? "bg-gray-700" : "bg-gray-200"
181
+ )}
182
+ >
183
+ <Ionicons
184
+ name="person"
185
+ size={customSize ? customSize * 0.5 : config.icon}
186
+ color={isDark ? "#94a3b8" : "#64748b"}
187
+ />
188
+ </View>
189
+ );
190
+ };
191
+
192
+ return (
193
+ <View
194
+ className={cn("relative", className)}
195
+ style={{ width: containerSize, height: containerSize }}
196
+ >
197
+ <View
198
+ className={cn("overflow-hidden rounded-full", borderClassName)}
199
+ style={{
200
+ width: containerSize,
201
+ height: containerSize,
202
+ borderRadius: containerSize / 2,
203
+ }}
204
+ >
205
+ {renderContent()}
206
+ </View>
207
+
208
+ {/* Online indicator */}
209
+ {showOnlineIndicator && (
210
+ <View
211
+ style={{
212
+ width: config.indicator,
213
+ height: config.indicator,
214
+ borderRadius: config.indicator / 2,
215
+ borderWidth: 2,
216
+ position: "absolute",
217
+ bottom: 0,
218
+ right: 0,
219
+ }}
220
+ className={cn(
221
+ isOnline ? "bg-green-500" : "bg-gray-400",
222
+ isDark ? "border-background-dark" : "border-white"
223
+ )}
224
+ />
225
+ )}
226
+ </View>
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Avatar Group component for displaying multiple avatars
232
+ */
233
+ interface AvatarGroupProps {
234
+ /**
235
+ * Array of avatar data
236
+ */
237
+ avatars: {
238
+ source?: string | null;
239
+ name?: string;
240
+ }[];
241
+
242
+ /**
243
+ * Maximum number of avatars to display
244
+ */
245
+ max?: number;
246
+
247
+ /**
248
+ * Size variant
249
+ */
250
+ size?: AvatarSize;
251
+
252
+ /**
253
+ * Additional class name
254
+ */
255
+ className?: string;
256
+ }
257
+
258
+ export function AvatarGroup({
259
+ avatars,
260
+ max = 4,
261
+ size = "md",
262
+ className,
263
+ }: AvatarGroupProps) {
264
+ const { isDark } = useTheme();
265
+ const config = sizeConfig[size];
266
+ const displayAvatars = avatars.slice(0, max);
267
+ const remainingCount = avatars.length - max;
268
+
269
+ return (
270
+ <View className={cn("flex-row items-center", className)}>
271
+ {displayAvatars.map((avatar, index) => (
272
+ <View
273
+ key={index}
274
+ style={{
275
+ marginLeft: index > 0 ? -config.container / 3 : 0,
276
+ zIndex: displayAvatars.length - index,
277
+ }}
278
+ >
279
+ <Avatar
280
+ source={avatar.source}
281
+ name={avatar.name}
282
+ size={size}
283
+ borderClassName={cn(
284
+ "border-2",
285
+ isDark ? "border-background-dark" : "border-white"
286
+ )}
287
+ />
288
+ </View>
289
+ ))}
290
+
291
+ {remainingCount > 0 && (
292
+ <View
293
+ style={{
294
+ marginLeft: -config.container / 3,
295
+ width: config.container,
296
+ height: config.container,
297
+ borderRadius: config.container / 2,
298
+ }}
299
+ className={cn(
300
+ "items-center justify-center border-2",
301
+ isDark
302
+ ? "bg-gray-700 border-background-dark"
303
+ : "bg-gray-200 border-white"
304
+ )}
305
+ >
306
+ <Text
307
+ className={cn(
308
+ config.text,
309
+ "font-medium",
310
+ isDark ? "text-text-dark" : "text-text-light"
311
+ )}
312
+ >
313
+ +{remainingCount}
314
+ </Text>
315
+ </View>
316
+ )}
317
+ </View>
318
+ );
319
+ }