@croacroa/react-native-template 2.1.0 → 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 -21
  10. package/README.md +446 -402
  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 -418
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -30
  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 -40
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +5 -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 -375
  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 -176
  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,9 +1,17 @@
1
- import { View, Text, Pressable, KeyboardAvoidingView, Platform, ScrollView } from "react-native";
1
+ import {
2
+ View,
3
+ Text,
4
+ Pressable,
5
+ KeyboardAvoidingView,
6
+ Platform,
7
+ ScrollView,
8
+ } from "react-native";
2
9
  import { Link, router } from "expo-router";
3
10
  import { SafeAreaView } from "react-native-safe-area-context";
4
11
  import { useForm } from "react-hook-form";
5
12
  import { zodResolver } from "@hookform/resolvers/zod";
6
13
  import Animated, { FadeInDown } from "react-native-reanimated";
14
+ import { useTranslation } from "react-i18next";
7
15
 
8
16
  import { FormInput } from "@/components/forms/FormInput";
9
17
  import { AnimatedButton } from "@/components/ui/AnimatedButton";
@@ -12,6 +20,7 @@ import { registerSchema, RegisterFormData } from "@/utils/validation";
12
20
 
13
21
  export default function RegisterScreen() {
14
22
  const { signUp } = useAuth();
23
+ const { t } = useTranslation();
15
24
 
16
25
  const {
17
26
  control,
@@ -53,10 +62,10 @@ export default function RegisterScreen() {
53
62
  className="mb-8"
54
63
  >
55
64
  <Text className="text-3xl font-bold text-text-light dark:text-text-dark">
56
- Create account
65
+ {t("auth.createAccount")}
57
66
  </Text>
58
67
  <Text className="mt-2 text-muted-light dark:text-muted-dark">
59
- Sign up to get started
68
+ {t("auth.joinUs")}
60
69
  </Text>
61
70
  </Animated.View>
62
71
 
@@ -68,8 +77,8 @@ export default function RegisterScreen() {
68
77
  <FormInput
69
78
  name="name"
70
79
  control={control}
71
- label="Name"
72
- placeholder="Enter your name"
80
+ label={t("auth.name")}
81
+ placeholder={t("auth.enterName")}
73
82
  autoComplete="name"
74
83
  leftIcon="person-outline"
75
84
  />
@@ -77,8 +86,8 @@ export default function RegisterScreen() {
77
86
  <FormInput
78
87
  name="email"
79
88
  control={control}
80
- label="Email"
81
- placeholder="Enter your email"
89
+ label={t("auth.email")}
90
+ placeholder={t("auth.enterEmail")}
82
91
  keyboardType="email-address"
83
92
  autoCapitalize="none"
84
93
  autoComplete="email"
@@ -88,19 +97,19 @@ export default function RegisterScreen() {
88
97
  <FormInput
89
98
  name="password"
90
99
  control={control}
91
- label="Password"
92
- placeholder="Create a password"
100
+ label={t("auth.password")}
101
+ placeholder={t("auth.createPasswordPlaceholder")}
93
102
  secureTextEntry
94
103
  autoComplete="new-password"
95
104
  leftIcon="lock-closed-outline"
96
- hint="Min 8 chars, 1 uppercase, 1 lowercase, 1 number"
105
+ hint={t("auth.passwordHintFull")}
97
106
  />
98
107
 
99
108
  <FormInput
100
109
  name="confirmPassword"
101
110
  control={control}
102
- label="Confirm Password"
103
- placeholder="Confirm your password"
111
+ label={t("auth.confirmPassword")}
112
+ placeholder={t("auth.confirmPasswordPlaceholder")}
104
113
  secureTextEntry
105
114
  autoComplete="new-password"
106
115
  leftIcon="lock-closed-outline"
@@ -111,7 +120,7 @@ export default function RegisterScreen() {
111
120
  isLoading={isSubmitting}
112
121
  className="mt-4"
113
122
  >
114
- Create Account
123
+ {t("auth.createAccount")}
115
124
  </AnimatedButton>
116
125
  </Animated.View>
117
126
 
@@ -121,12 +130,12 @@ export default function RegisterScreen() {
121
130
  className="mt-8 flex-row justify-center"
122
131
  >
123
132
  <Text className="text-muted-light dark:text-muted-dark">
124
- Already have an account?{" "}
133
+ {t("auth.haveAccount")}{" "}
125
134
  </Text>
126
135
  <Link href="/(public)/login" asChild>
127
136
  <Pressable>
128
137
  <Text className="font-semibold text-primary-600 dark:text-primary-400">
129
- Sign In
138
+ {t("auth.signIn")}
130
139
  </Text>
131
140
  </Pressable>
132
141
  </Link>
package/app/_layout.tsx CHANGED
@@ -8,6 +8,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
8
8
  import { SafeAreaProvider } from "react-native-safe-area-context";
9
9
 
10
10
  import { ErrorBoundary } from "@/components/ErrorBoundary";
11
+ import { AnalyticsProvider } from "@/components/providers/AnalyticsProvider";
11
12
  import { AuthProvider } from "@/hooks/useAuth";
12
13
  import { ThemeProvider, useTheme } from "@/hooks/useTheme";
13
14
  import { useNotifications } from "@/hooks/useNotifications";
@@ -85,9 +86,11 @@ export default function RootLayout() {
85
86
  persistOptions={persistOptions}
86
87
  >
87
88
  <ThemeProvider>
88
- <AuthProvider>
89
- <RootLayoutContent />
90
- </AuthProvider>
89
+ <AnalyticsProvider>
90
+ <AuthProvider>
91
+ <RootLayoutContent />
92
+ </AuthProvider>
93
+ </AnalyticsProvider>
91
94
  </ThemeProvider>
92
95
  </PersistQueryClientProvider>
93
96
  </SafeAreaProvider>
package/app.config.ts CHANGED
@@ -18,7 +18,7 @@ const getAppName = () => {
18
18
  export default ({ config }: ConfigContext): ExpoConfig => ({
19
19
  ...config,
20
20
  name: getAppName(),
21
- slug: "your-app",
21
+ slug: "react-native-template",
22
22
  version: "1.0.0",
23
23
  orientation: "portrait",
24
24
  icon: "./assets/images/icon.png",
@@ -35,6 +35,16 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
35
35
  bundleIdentifier: getUniqueIdentifier(),
36
36
  infoPlist: {
37
37
  UIBackgroundModes: ["remote-notification", "fetch", "processing"],
38
+ ITSAppUsesNonExemptEncryption: false,
39
+ NSCameraUsageDescription: "This app uses the camera to take photos.",
40
+ NSPhotoLibraryUsageDescription:
41
+ "This app accesses your photo library to select images.",
42
+ NSLocationWhenInUseUsageDescription:
43
+ "This app uses your location to show nearby results.",
44
+ NSContactsUsageDescription:
45
+ "This app accesses your contacts to find friends.",
46
+ NSMicrophoneUsageDescription:
47
+ "This app uses the microphone to record audio.",
38
48
  },
39
49
  },
40
50
  android: {
@@ -47,6 +57,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
47
57
  "NOTIFICATIONS",
48
58
  "RECEIVE_BOOT_COMPLETED",
49
59
  "FOREGROUND_SERVICE",
60
+ "CAMERA",
61
+ "READ_CONTACTS",
62
+ "ACCESS_FINE_LOCATION",
63
+ "ACCESS_COARSE_LOCATION",
64
+ "READ_MEDIA_IMAGES",
65
+ "READ_MEDIA_VIDEO",
66
+ "RECORD_AUDIO",
50
67
  ],
51
68
  },
52
69
  web: {
@@ -87,6 +104,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
87
104
  plugins: [
88
105
  "expo-router",
89
106
  "expo-secure-store",
107
+ "expo-image-picker",
108
+ "@sentry/react-native",
90
109
  [
91
110
  "expo-notifications",
92
111
  {
@@ -103,13 +122,19 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
103
122
  },
104
123
  ],
105
124
  "expo-task-manager",
125
+ [
126
+ "expo-camera",
127
+ {
128
+ cameraPermission: "This app uses the camera to take photos.",
129
+ },
130
+ ],
106
131
  ],
107
132
  experiments: {
108
133
  typedRoutes: true,
109
134
  },
110
135
  extra: {
111
136
  eas: {
112
- projectId: "your-project-id",
137
+ projectId: "e683c5b3-fb16-4a31-b578-01f3fca1e67a",
113
138
  },
114
139
  },
115
140
  });
@@ -1,7 +1,7 @@
1
- # Placeholder for app assets
2
- # Replace these files with your own:
3
- # - icon.png (1024x1024)
4
- # - splash.png (1284x2778)
5
- # - adaptive-icon.png (1024x1024)
6
- # - favicon.png (48x48)
7
- # - notification-icon.png (96x96, white on transparent)
1
+ # Placeholder for app assets
2
+ # Replace these files with your own:
3
+ # - icon.png (1024x1024)
4
+ # - splash.png (1284x2778)
5
+ # - adaptive-icon.png (1024x1024)
6
+ # - favicon.png (48x48)
7
+ # - notification-icon.png (96x96, white on transparent)
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -2,7 +2,9 @@ import { Component, ErrorInfo, ReactNode } from "react";
2
2
  import { View, Text, Pressable, ScrollView } from "react-native";
3
3
  import { Ionicons } from "@expo/vector-icons";
4
4
  import * as Updates from "expo-updates";
5
- import { captureException, addBreadcrumb } from "@/services/sentry";
5
+ import i18next from "i18next";
6
+ import { captureException, addBreadcrumb, Sentry } from "@/services/sentry";
7
+ import { useAppStore } from "@/stores/appStore";
6
8
 
7
9
  interface Props {
8
10
  children: ReactNode;
@@ -13,12 +15,18 @@ interface State {
13
15
  hasError: boolean;
14
16
  error: Error | null;
15
17
  errorInfo: ErrorInfo | null;
18
+ crashCount: number;
16
19
  }
17
20
 
21
+ const MAX_SOFT_RESETS = 3;
22
+
18
23
  /**
19
24
  * Global Error Boundary component
20
25
  * Catches JavaScript errors anywhere in the child component tree
21
- * and displays a fallback UI instead of crashing the whole app
26
+ * and displays a fallback UI instead of crashing the whole app.
27
+ *
28
+ * After multiple consecutive soft-reset failures, automatically
29
+ * offers a hard reset (clears stores + full app restart).
22
30
  */
23
31
  export class ErrorBoundary extends Component<Props, State> {
24
32
  constructor(props: Props) {
@@ -27,6 +35,7 @@ export class ErrorBoundary extends Component<Props, State> {
27
35
  hasError: false,
28
36
  error: null,
29
37
  errorInfo: null,
38
+ crashCount: 0,
30
39
  };
31
40
  }
32
41
 
@@ -35,9 +44,17 @@ export class ErrorBoundary extends Component<Props, State> {
35
44
  }
36
45
 
37
46
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
38
- this.setState({ errorInfo });
47
+ this.setState((prev) => {
48
+ const crashCount = prev.crashCount + 1;
49
+ const lastCrashTime = new Date().toISOString();
50
+
51
+ // Set Sentry context for crash recovery debugging
52
+ Sentry.setContext("crash_recovery", { crashCount, lastCrashTime });
53
+
54
+ return { errorInfo, crashCount };
55
+ });
39
56
 
40
- // Log to your error reporting service (Sentry, Bugsnag, etc.)
57
+ // Log to error reporting service
41
58
  this.logError(error, errorInfo);
42
59
  }
43
60
 
@@ -57,30 +74,41 @@ export class ErrorBoundary extends Component<Props, State> {
57
74
  console.error("Component stack:", errorInfo.componentStack);
58
75
  }
59
76
 
60
- private handleRestart = async () => {
77
+ /**
78
+ * Hard reset: clears all Zustand persisted state and reloads the app.
79
+ */
80
+ private handleHardReset = async () => {
61
81
  try {
62
- // Try to reload the app using expo-updates
82
+ // Reset Zustand stores
83
+ useAppStore.getState().reset();
84
+
85
+ // Full app restart via expo-updates
63
86
  if (!__DEV__) {
64
87
  await Updates.reloadAsync();
65
88
  } else {
66
- // In dev, just reset the error state
89
+ // In dev, just reset the error state and crash counter
67
90
  this.setState({
68
91
  hasError: false,
69
92
  error: null,
70
93
  errorInfo: null,
94
+ crashCount: 0,
71
95
  });
72
96
  }
73
- } catch (e) {
74
- // If Updates.reloadAsync fails, reset state
97
+ } catch {
98
+ // Last resort: reset error state so user isn't stuck
75
99
  this.setState({
76
100
  hasError: false,
77
101
  error: null,
78
102
  errorInfo: null,
103
+ crashCount: 0,
79
104
  });
80
105
  }
81
106
  };
82
107
 
83
- private handleReset = () => {
108
+ /**
109
+ * Soft reset: simply re-renders the children by clearing the error state.
110
+ */
111
+ private handleSoftReset = () => {
84
112
  this.setState({
85
113
  hasError: false,
86
114
  error: null,
@@ -94,6 +122,8 @@ export class ErrorBoundary extends Component<Props, State> {
94
122
  return this.props.fallback;
95
123
  }
96
124
 
125
+ const showHardReset = this.state.crashCount >= MAX_SOFT_RESETS;
126
+
97
127
  return (
98
128
  <View className="flex-1 items-center justify-center bg-background-light px-6 dark:bg-background-dark">
99
129
  {/* Error Icon */}
@@ -103,13 +133,12 @@ export class ErrorBoundary extends Component<Props, State> {
103
133
 
104
134
  {/* Title */}
105
135
  <Text className="mb-2 text-center text-2xl font-bold text-text-light dark:text-text-dark">
106
- Oops! Something went wrong
136
+ {i18next.t("errors.crashTitle")}
107
137
  </Text>
108
138
 
109
139
  {/* Description */}
110
140
  <Text className="mb-8 text-center text-muted-light dark:text-muted-dark">
111
- The app ran into a problem and could not continue. We apologize for
112
- any inconvenience.
141
+ {i18next.t("errors.crashMessage")}
113
142
  </Text>
114
143
 
115
144
  {/* Error details (dev only) */}
@@ -128,21 +157,37 @@ export class ErrorBoundary extends Component<Props, State> {
128
157
 
129
158
  {/* Action buttons */}
130
159
  <View className="w-full gap-3">
131
- <Pressable
132
- onPress={this.handleRestart}
133
- className="w-full items-center rounded-xl bg-primary-600 py-4"
134
- >
135
- <Text className="font-semibold text-white">Restart App</Text>
136
- </Pressable>
137
-
138
- <Pressable
139
- onPress={this.handleReset}
140
- className="w-full items-center rounded-xl border-2 border-gray-300 py-4 dark:border-gray-600"
141
- >
142
- <Text className="font-semibold text-text-light dark:text-text-dark">
143
- Try Again
144
- </Text>
145
- </Pressable>
160
+ {showHardReset ? (
161
+ // After MAX_SOFT_RESETS consecutive failures, show hard reset
162
+ <Pressable
163
+ onPress={this.handleHardReset}
164
+ className="w-full items-center rounded-xl bg-red-600 py-4"
165
+ >
166
+ <Text className="font-semibold text-white">
167
+ {i18next.t("errors.restartApp")}
168
+ </Text>
169
+ </Pressable>
170
+ ) : (
171
+ <>
172
+ <Pressable
173
+ onPress={this.handleSoftReset}
174
+ className="w-full items-center rounded-xl bg-primary-600 py-4"
175
+ >
176
+ <Text className="font-semibold text-white">
177
+ {i18next.t("errors.tryAgain")}
178
+ </Text>
179
+ </Pressable>
180
+
181
+ <Pressable
182
+ onPress={this.handleHardReset}
183
+ className="w-full items-center rounded-xl border-2 border-gray-300 py-4 dark:border-gray-600"
184
+ >
185
+ <Text className="font-semibold text-text-light dark:text-text-dark">
186
+ {i18next.t("errors.restartApp")}
187
+ </Text>
188
+ </Pressable>
189
+ </>
190
+ )}
146
191
  </View>
147
192
  </View>
148
193
  );
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @fileoverview Social login buttons component
3
+ * Renders styled Google and Apple sign-in buttons with loading states.
4
+ * Apple button is only shown on iOS devices.
5
+ * @module components/auth/SocialLoginButtons
6
+ */
7
+
8
+ import { useState } from "react";
9
+ import {
10
+ View,
11
+ Text,
12
+ Pressable,
13
+ ActivityIndicator,
14
+ useColorScheme,
15
+ type ViewProps,
16
+ } from "react-native";
17
+ import { Ionicons } from "@expo/vector-icons";
18
+ import { useTranslation } from "react-i18next";
19
+
20
+ import {
21
+ SocialAuth,
22
+ isAppleSignInAvailable,
23
+ } from "@/services/auth/social/social-auth";
24
+ import type { SocialAuthResult } from "@/services/auth/social/types";
25
+ import { cn } from "@/utils/cn";
26
+
27
+ // ============================================================================
28
+ // Types
29
+ // ============================================================================
30
+
31
+ interface SocialLoginButtonsProps extends ViewProps {
32
+ /** Callback when social sign-in succeeds */
33
+ onSuccess: (result: SocialAuthResult) => void;
34
+ /** Callback when social sign-in fails */
35
+ onError?: (error: Error) => void;
36
+ /** Disable all buttons (e.g., while another form is submitting) */
37
+ disabled?: boolean;
38
+ }
39
+
40
+ // ============================================================================
41
+ // Component
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Social login buttons for Google and Apple Sign-In.
46
+ *
47
+ * - Google button is always shown
48
+ * - Apple button is only shown on iOS
49
+ * - Each button has its own loading state
50
+ * - Supports dark mode via NativeWind
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * <SocialLoginButtons
55
+ * onSuccess={(result) => {
56
+ * // Send result.idToken to your backend
57
+ * }}
58
+ * onError={(error) => {
59
+ * Alert.alert('Error', error.message);
60
+ * }}
61
+ * />
62
+ * ```
63
+ */
64
+ export function SocialLoginButtons({
65
+ onSuccess,
66
+ onError,
67
+ disabled = false,
68
+ className,
69
+ ...viewProps
70
+ }: SocialLoginButtonsProps) {
71
+ const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
72
+ const colorScheme = useColorScheme();
73
+ const { t } = useTranslation();
74
+
75
+ const showApple = isAppleSignInAvailable();
76
+ const isLoading = loadingProvider !== null;
77
+ const isDark = colorScheme === "dark";
78
+
79
+ const handleSignIn = async (provider: "google" | "apple") => {
80
+ if (isLoading || disabled) return;
81
+
82
+ setLoadingProvider(provider);
83
+
84
+ try {
85
+ const result = await SocialAuth.signIn(provider);
86
+
87
+ if (result) {
88
+ onSuccess(result);
89
+ }
90
+ } catch (error) {
91
+ const authError =
92
+ error instanceof Error ? error : new Error("Social sign-in failed");
93
+ onError?.(authError);
94
+ } finally {
95
+ setLoadingProvider(null);
96
+ }
97
+ };
98
+
99
+ // Apple button colors are inverted in dark mode
100
+ const appleIconColor = isDark ? "#000000" : "#FFFFFF";
101
+ const appleSpinnerColor = isDark ? "#000000" : "#FFFFFF";
102
+
103
+ return (
104
+ <View className={cn("gap-3", className)} {...viewProps}>
105
+ {/* Google Sign-In Button */}
106
+ <Pressable
107
+ onPress={() => handleSignIn("google")}
108
+ disabled={isLoading || disabled}
109
+ accessibilityRole="button"
110
+ accessibilityLabel="Continue with Google"
111
+ className={cn(
112
+ "flex-row items-center justify-center rounded-xl border border-gray-300 bg-white px-4 py-3.5",
113
+ "dark:border-gray-600 dark:bg-gray-100",
114
+ "active:bg-gray-50 dark:active:bg-gray-200",
115
+ (isLoading || disabled) && "opacity-50"
116
+ )}
117
+ >
118
+ {loadingProvider === "google" ? (
119
+ <ActivityIndicator size="small" color="#4285F4" />
120
+ ) : (
121
+ <>
122
+ <Ionicons
123
+ name="logo-google"
124
+ size={20}
125
+ color="#4285F4"
126
+ style={{ marginRight: 12 }}
127
+ />
128
+ <Text className="text-base font-semibold text-gray-700">
129
+ {t("socialAuth.continueWithGoogle")}
130
+ </Text>
131
+ </>
132
+ )}
133
+ </Pressable>
134
+
135
+ {/* Apple Sign-In Button (iOS only) */}
136
+ {showApple && (
137
+ <Pressable
138
+ onPress={() => handleSignIn("apple")}
139
+ disabled={isLoading || disabled}
140
+ accessibilityRole="button"
141
+ accessibilityLabel="Continue with Apple"
142
+ className={cn(
143
+ "flex-row items-center justify-center rounded-xl bg-black px-4 py-3.5",
144
+ "dark:bg-white",
145
+ "active:opacity-80",
146
+ (isLoading || disabled) && "opacity-50"
147
+ )}
148
+ >
149
+ {loadingProvider === "apple" ? (
150
+ <ActivityIndicator size="small" color={appleSpinnerColor} />
151
+ ) : (
152
+ <>
153
+ <Ionicons
154
+ name="logo-apple"
155
+ size={20}
156
+ color={appleIconColor}
157
+ style={{ marginRight: 12 }}
158
+ />
159
+ <Text className="text-base font-semibold text-white dark:text-black">
160
+ {t("socialAuth.continueWithApple")}
161
+ </Text>
162
+ </>
163
+ )}
164
+ </Pressable>
165
+ )}
166
+ </View>
167
+ );
168
+ }
@@ -10,8 +10,10 @@ import {
10
10
  import { Input } from "@/components/ui/Input";
11
11
  import { Ionicons } from "@expo/vector-icons";
12
12
 
13
- interface FormInputProps<T extends FieldValues>
14
- extends Omit<TextInputProps, "value" | "onChangeText"> {
13
+ interface FormInputProps<T extends FieldValues> extends Omit<
14
+ TextInputProps,
15
+ "value" | "onChangeText"
16
+ > {
15
17
  name: Path<T>;
16
18
  control: Control<T>;
17
19
  rules?: RegisterOptions<T, Path<T>>;
@@ -29,7 +31,7 @@ interface FormInputProps<T extends FieldValues>
29
31
  * Automatically handles validation errors and form state
30
32
  */
31
33
  export const FormInput = forwardRef(function FormInputInner<
32
- T extends FieldValues
34
+ T extends FieldValues,
33
35
  >(
34
36
  {
35
37
  name,