@croacroa/react-native-template 1.0.0 → 2.0.1
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.
- package/.github/workflows/ci.yml +187 -184
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/CHANGELOG.md +106 -106
- package/CONTRIBUTING.md +377 -377
- package/README.md +399 -399
- package/__tests__/components/snapshots.test.tsx +131 -0
- package/__tests__/integration/auth-api.test.tsx +227 -0
- package/__tests__/performance/VirtualizedList.perf.test.tsx +362 -0
- package/app/(public)/onboarding.tsx +5 -5
- package/app.config.ts +45 -2
- package/assets/images/.gitkeep +7 -7
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/SuspenseBoundary.tsx +357 -0
- package/components/providers/index.ts +21 -0
- package/components/ui/Avatar.tsx +316 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Select.tsx +240 -240
- package/components/ui/VirtualizedList.tsx +285 -0
- package/components/ui/index.ts +23 -18
- package/constants/config.ts +97 -54
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/hooks/index.ts +27 -25
- package/hooks/useApi.ts +102 -5
- package/hooks/useAuth.tsx +82 -0
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useMFA.ts +499 -0
- package/hooks/useNotifications.ts +39 -0
- package/hooks/useOffline.ts +60 -6
- package/hooks/usePerformance.ts +434 -434
- package/hooks/useTheme.tsx +76 -0
- package/hooks/useUpdates.ts +358 -358
- package/i18n/index.ts +194 -77
- package/i18n/locales/ar.json +101 -0
- package/i18n/locales/de.json +101 -0
- package/i18n/locales/en.json +101 -101
- package/i18n/locales/es.json +101 -0
- package/i18n/locales/fr.json +101 -101
- package/jest.config.js +4 -4
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -0
- package/maestro/flows/mfa-setup.yaml +86 -0
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -0
- package/maestro/flows/offline-sync.yaml +128 -0
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +175 -170
- package/services/analytics.ts +428 -428
- package/services/api.ts +340 -340
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +626 -0
- package/services/index.ts +54 -22
- package/services/security.ts +286 -0
- package/tailwind.config.js +47 -47
- package/utils/accessibility.ts +446 -446
- package/utils/index.ts +52 -43
- package/utils/validation.ts +2 -1
- package/utils/withAccessibility.tsx +272 -0
package/components/ui/Avatar.tsx
CHANGED
|
@@ -1,316 +1,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
|
-
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
|
+
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
|
+
}
|