@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.
Files changed (70) hide show
  1. package/.github/workflows/ci.yml +187 -184
  2. package/.github/workflows/eas-build.yml +55 -55
  3. package/.github/workflows/eas-update.yml +50 -50
  4. package/CHANGELOG.md +106 -106
  5. package/CONTRIBUTING.md +377 -377
  6. package/README.md +399 -399
  7. package/__tests__/components/snapshots.test.tsx +131 -0
  8. package/__tests__/integration/auth-api.test.tsx +227 -0
  9. package/__tests__/performance/VirtualizedList.perf.test.tsx +362 -0
  10. package/app/(public)/onboarding.tsx +5 -5
  11. package/app.config.ts +45 -2
  12. package/assets/images/.gitkeep +7 -7
  13. package/components/onboarding/OnboardingScreen.tsx +370 -370
  14. package/components/onboarding/index.ts +2 -2
  15. package/components/providers/SuspenseBoundary.tsx +357 -0
  16. package/components/providers/index.ts +21 -0
  17. package/components/ui/Avatar.tsx +316 -316
  18. package/components/ui/Badge.tsx +416 -416
  19. package/components/ui/BottomSheet.tsx +307 -307
  20. package/components/ui/Checkbox.tsx +261 -261
  21. package/components/ui/OptimizedImage.tsx +369 -369
  22. package/components/ui/Select.tsx +240 -240
  23. package/components/ui/VirtualizedList.tsx +285 -0
  24. package/components/ui/index.ts +23 -18
  25. package/constants/config.ts +97 -54
  26. package/docs/adr/001-state-management.md +79 -79
  27. package/docs/adr/002-styling-approach.md +130 -130
  28. package/docs/adr/003-data-fetching.md +155 -155
  29. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  30. package/docs/adr/README.md +78 -78
  31. package/hooks/index.ts +27 -25
  32. package/hooks/useApi.ts +102 -5
  33. package/hooks/useAuth.tsx +82 -0
  34. package/hooks/useBiometrics.ts +295 -295
  35. package/hooks/useDeepLinking.ts +256 -256
  36. package/hooks/useMFA.ts +499 -0
  37. package/hooks/useNotifications.ts +39 -0
  38. package/hooks/useOffline.ts +60 -6
  39. package/hooks/usePerformance.ts +434 -434
  40. package/hooks/useTheme.tsx +76 -0
  41. package/hooks/useUpdates.ts +358 -358
  42. package/i18n/index.ts +194 -77
  43. package/i18n/locales/ar.json +101 -0
  44. package/i18n/locales/de.json +101 -0
  45. package/i18n/locales/en.json +101 -101
  46. package/i18n/locales/es.json +101 -0
  47. package/i18n/locales/fr.json +101 -101
  48. package/jest.config.js +4 -4
  49. package/maestro/README.md +113 -113
  50. package/maestro/config.yaml +35 -35
  51. package/maestro/flows/login.yaml +62 -62
  52. package/maestro/flows/mfa-login.yaml +92 -0
  53. package/maestro/flows/mfa-setup.yaml +86 -0
  54. package/maestro/flows/navigation.yaml +68 -68
  55. package/maestro/flows/offline-conflict.yaml +101 -0
  56. package/maestro/flows/offline-sync.yaml +128 -0
  57. package/maestro/flows/offline.yaml +60 -60
  58. package/maestro/flows/register.yaml +94 -94
  59. package/package.json +175 -170
  60. package/services/analytics.ts +428 -428
  61. package/services/api.ts +340 -340
  62. package/services/authAdapter.ts +333 -333
  63. package/services/backgroundSync.ts +626 -0
  64. package/services/index.ts +54 -22
  65. package/services/security.ts +286 -0
  66. package/tailwind.config.js +47 -47
  67. package/utils/accessibility.ts +446 -446
  68. package/utils/index.ts +52 -43
  69. package/utils/validation.ts +2 -1
  70. package/utils/withAccessibility.tsx +272 -0
@@ -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
+ }