@highbeek/create-rnstarterkit 1.0.2-beta.7 → 1.1.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +268 -0
  3. package/dist/bin/create-rnstarterkit.js +205 -7
  4. package/dist/src/generators/appGenerator.js +1944 -127
  5. package/dist/src/generators/codeGenerator.js +289 -0
  6. package/dist/templates/cli-base/App.tsx +199 -21
  7. package/dist/templates/cli-base/assets/images/icon.png +0 -0
  8. package/dist/templates/cli-base/assets/images/partial-react-logo.png +0 -0
  9. package/dist/templates/cli-base/assets/images/react-logo.png +0 -0
  10. package/dist/templates/cli-base/babel.config.js +1 -0
  11. package/dist/templates/cli-base/ios/BaseApp.xcodeproj/project.pbxproj +6 -0
  12. package/dist/templates/cli-base/ios/Podfile +5 -0
  13. package/dist/templates/cli-base/jest.config.js +4 -0
  14. package/dist/templates/cli-base/package.json +7 -4
  15. package/dist/templates/cli-base/tsconfig.json +1 -0
  16. package/dist/templates/expo-base/app/_layout.tsx +7 -3
  17. package/dist/templates/expo-base/app/home.tsx +37 -0
  18. package/dist/templates/expo-base/app/index.tsx +166 -5
  19. package/dist/templates/expo-base/app.json +1 -2
  20. package/dist/templates/expo-base/package.json +5 -2
  21. package/dist/templates/optional/auth-context/components/layout/ScreenLayout.tsx +46 -0
  22. package/dist/templates/optional/auth-context/navigation/ProtectedStack.tsx +33 -5
  23. package/dist/templates/optional/auth-context/screens/HomeScreen.tsx +4 -3
  24. package/dist/templates/optional/auth-context/screens/LoginScreen.tsx +41 -6
  25. package/dist/templates/optional/auth-context/screens/ProfileScreen.tsx +4 -3
  26. package/dist/templates/optional/auth-context/screens/RegisterScreen.tsx +41 -6
  27. package/dist/templates/optional/auth-context/screens/SettingsScreen.tsx +4 -3
  28. package/dist/templates/optional/auth-context/screens/WelcomeScreen.tsx +174 -0
  29. package/dist/templates/optional/auth-redux/components/layout/ScreenLayout.tsx +46 -0
  30. package/dist/templates/optional/auth-redux/navigation/ProtectedStack.tsx +39 -2
  31. package/dist/templates/optional/auth-redux/screens/HomeScreen.tsx +4 -3
  32. package/dist/templates/optional/auth-redux/screens/LoginScreen.tsx +42 -7
  33. package/dist/templates/optional/auth-redux/screens/ProfileScreen.tsx +7 -10
  34. package/dist/templates/optional/auth-redux/screens/RegisterScreen.tsx +61 -11
  35. package/dist/templates/optional/auth-redux/screens/SettingsScreen.tsx +6 -9
  36. package/dist/templates/optional/auth-redux/screens/WelcomeScreen.tsx +174 -0
  37. package/dist/templates/optional/auth-zustand/components/layout/ScreenLayout.tsx +46 -0
  38. package/dist/templates/optional/auth-zustand/navigation/ProtectedStack.tsx +34 -6
  39. package/dist/templates/optional/auth-zustand/screens/HomeScreen.tsx +4 -3
  40. package/dist/templates/optional/auth-zustand/screens/LoginScreen.tsx +41 -6
  41. package/dist/templates/optional/auth-zustand/screens/ProfileScreen.tsx +4 -3
  42. package/dist/templates/optional/auth-zustand/screens/RegisterScreen.tsx +41 -6
  43. package/dist/templates/optional/auth-zustand/screens/SettingsScreen.tsx +4 -3
  44. package/dist/templates/optional/auth-zustand/screens/WelcomeScreen.tsx +174 -0
  45. package/dist/templates/optional/ci/.github/workflows/ci.yml +32 -0
  46. package/dist/templates/optional/error-boundary/components/ErrorBoundary.tsx +83 -0
  47. package/dist/templates/optional/formik/components/formik/FormikInput.tsx +45 -0
  48. package/dist/templates/optional/formik/components/formik/LoginForm.tsx +60 -0
  49. package/dist/templates/optional/formik/schemas/auth.schema.ts +17 -0
  50. package/dist/templates/optional/i18n/src/i18n/hooks/useAppTranslation.ts +28 -0
  51. package/dist/templates/optional/i18n/src/i18n/i18n.ts +30 -0
  52. package/dist/templates/optional/i18n/src/i18n/locales/en.json +32 -0
  53. package/dist/templates/optional/i18n/src/i18n/locales/es.json +32 -0
  54. package/dist/templates/optional/maestro/.maestro/flows/01_welcome.yaml +5 -0
  55. package/dist/templates/optional/maestro-auth/.maestro/flows/01_welcome.yaml +5 -0
  56. package/dist/templates/optional/maestro-auth/.maestro/flows/02_login.yaml +13 -0
  57. package/dist/templates/optional/maestro-auth/.maestro/flows/03_logout.yaml +16 -0
  58. package/dist/templates/optional/mmkv/utils/storage.ts +17 -0
  59. package/dist/templates/optional/react-hook-form/components/rhf/LoginForm.tsx +63 -0
  60. package/dist/templates/optional/react-hook-form/components/rhf/RHFInput.tsx +50 -0
  61. package/dist/templates/optional/react-hook-form/schemas/auth.schema.ts +29 -0
  62. package/dist/templates/optional/react-query/hooks/useAppMutation.ts +16 -0
  63. package/dist/templates/optional/react-query/hooks/useAppQuery.ts +12 -0
  64. package/dist/templates/optional/react-query/services/queryClient.ts +14 -0
  65. package/dist/templates/optional/redux/store/hooks.ts +6 -0
  66. package/dist/templates/optional/redux/store/store.ts +11 -0
  67. package/dist/templates/optional/sentry/src/utils/sentry.ts +24 -0
  68. package/dist/templates/optional/swr/hooks/useSWRFetch.ts +14 -0
  69. package/dist/templates/optional/swr/providers/SWRProvider.tsx +21 -0
  70. package/dist/templates/optional/tsconfig.json +17 -0
  71. package/dist/templates/optional/zustand/store/appStore.ts +13 -0
  72. package/package.json +40 -5
  73. package/dist/templates/expo-base/components/ui/collapsible.tsx +0 -45
  74. package/dist/templates/expo-base/components/ui/icon-symbol.ios.tsx +0 -32
  75. package/dist/templates/expo-base/components/ui/icon-symbol.tsx +0 -41
@@ -7,8 +7,12 @@ exports.generateApp = generateApp;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
9
  const execa_1 = require("execa");
10
+ const CLI_REANIMATED_VERSION = "^4.2.0";
11
+ const CLI_WORKLETS_VERSION = "^0.7.0";
12
+ const EXPO_REANIMATED_VERSION = "~4.1.1";
13
+ const EXPO_WORKLETS_VERSION = "0.5.1";
10
14
  async function generateApp(options) {
11
- const { platform, projectName, auth, apiClient, absoluteImports, state, dataFetching, validation, storage, typescript, apiClientType, } = options;
15
+ const { platform, projectName, auth, apiClient, absoluteImports, state, dataFetching, validation, storage, typescript, apiClientType, husky, sentry, i18n, maestro, } = options;
12
16
  const templateRoot = await resolveTemplateRoot();
13
17
  const templateFolder = platform === "Expo" ? "expo-base" : "cli-base";
14
18
  const templatePath = path_1.default.join(templateRoot, templateFolder);
@@ -18,7 +22,10 @@ async function generateApp(options) {
18
22
  filter: (src) => shouldCopyPath(src, templatePath),
19
23
  });
20
24
  await replaceProjectName(targetPath, projectName);
21
- await createStandardStructure(targetPath, platform);
25
+ if (platform === "React Native CLI") {
26
+ await configureCliNativeProjectNames(targetPath, projectName);
27
+ }
28
+ await createStandardStructure(targetPath, platform, state);
22
29
  if (auth) {
23
30
  const authFolder = state === "Redux Toolkit"
24
31
  ? "auth-redux"
@@ -26,30 +33,34 @@ async function generateApp(options) {
26
33
  ? "auth-zustand"
27
34
  : "auth-context";
28
35
  await copyOptionalModule(authFolder, targetPath);
29
- if (authFolder === "auth-context" ||
30
- authFolder === "auth-zustand" ||
31
- authFolder === "auth-redux") {
32
- if (platform === "Expo") {
33
- await writeExpoRouterAuthRoutes(targetPath, state);
34
- }
35
- else {
36
- await setAuthTabsByPlatform(targetPath, platform);
37
- await writeAuthAppShell(targetPath, state);
38
- }
36
+ if (platform === "Expo") {
37
+ await writeExpoRouterAuthRoutes(targetPath, state, dataFetching);
38
+ }
39
+ else {
40
+ await setAuthTabsByPlatform(targetPath, platform);
41
+ await writeAuthAppShell(targetPath, state, dataFetching);
39
42
  }
40
43
  }
41
44
  if (apiClient) {
42
45
  await copyOptionalModule("apiClient", targetPath);
43
46
  await configureApiClientByPlatform(targetPath, platform);
44
47
  }
45
- if (state === "Redux Toolkit")
48
+ // Only copy standalone state modules when auth is disabled
49
+ // (auth modules already include their own store setup)
50
+ if (state === "Redux Toolkit" && !auth)
46
51
  await copyOptionalModule("redux", targetPath);
47
- if (state === "Zustand")
52
+ if (state === "Zustand" && !auth)
48
53
  await copyOptionalModule("zustand", targetPath);
49
54
  if (dataFetching === "React Query")
50
55
  await copyOptionalModule("react-query", targetPath);
56
+ if (dataFetching === "SWR")
57
+ await copyOptionalModule("swr", targetPath);
51
58
  if (storage === "MMKV")
52
59
  await copyOptionalModule("mmkv", targetPath);
60
+ if (validation.includes("Formik"))
61
+ await copyOptionalModule("formik", targetPath);
62
+ if (validation.includes("React Hook Form"))
63
+ await copyOptionalModule("react-hook-form", targetPath);
53
64
  await configureStateAndAuthDependencies(targetPath, {
54
65
  platform,
55
66
  state,
@@ -61,19 +72,37 @@ async function generateApp(options) {
61
72
  await configureApiClientTransport(targetPath, apiClient, apiClientType);
62
73
  await syncApiIndex(targetPath);
63
74
  await configureValidation(targetPath, validation);
75
+ await configureTheme(targetPath);
76
+ await configureErrorBoundary(targetPath);
77
+ await configureTesting(targetPath);
78
+ await configureNavigationTypes(targetPath, { platform, auth, state });
79
+ await configureEnv(targetPath, platform);
80
+ await configureVSCode(targetPath);
81
+ if (sentry)
82
+ await configureSentry(targetPath, platform);
83
+ if (i18n)
84
+ await configureI18n(targetPath, platform);
85
+ if (maestro)
86
+ await configureMaestro(targetPath, { auth, ci: options.ci });
87
+ if (options.nativewind)
88
+ await configureNativeWind(targetPath, platform);
89
+ await stampVersion(targetPath, options);
90
+ if (options.ci)
91
+ await configureCi(targetPath);
92
+ if (husky)
93
+ await configureHusky(targetPath);
64
94
  if (typescript) {
65
- const tsconfigTemplate = path_1.default.join(templateRoot, "optional/tsconfig.json");
66
- if (await fs_extra_1.default.pathExists(tsconfigTemplate)) {
67
- await fs_extra_1.default.copy(tsconfigTemplate, path_1.default.join(targetPath, "tsconfig.json"), {
68
- overwrite: true,
69
- });
70
- }
95
+ // Rename App.js -> App.tsx for projects generated without a .tsx entry point.
96
+ // Both cli-base and expo-base templates already ship App.tsx and correct
97
+ // tsconfigs (extending @react-native/typescript-config / expo/tsconfig.base),
98
+ // so we must NOT overwrite those configs here.
71
99
  const appJs = path_1.default.join(targetPath, "App.js");
72
100
  const appTsx = path_1.default.join(targetPath, "App.tsx");
73
101
  if (await fs_extra_1.default.pathExists(appJs)) {
74
102
  await fs_extra_1.default.rename(appJs, appTsx);
75
103
  }
76
104
  }
105
+ await validateGeneratedRuntimeCompatibility(targetPath);
77
106
  await installDependencies(targetPath);
78
107
  console.log("✅ Project created successfully!");
79
108
  }
@@ -102,14 +131,81 @@ async function replaceProjectName(dir, projectName) {
102
131
  }
103
132
  }
104
133
  }
134
+ function toNativeAppName(projectName) {
135
+ const cleaned = projectName
136
+ .replace(/[^a-zA-Z0-9]+/g, " ")
137
+ .trim()
138
+ .split(/\s+/)
139
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
140
+ .join("");
141
+ const fallback = cleaned || "App";
142
+ return /^\d/.test(fallback) ? `App${fallback}` : fallback;
143
+ }
144
+ function toAndroidPackageSegment(projectName) {
145
+ const cleaned = projectName.toLowerCase().replace(/[^a-z0-9]+/g, "");
146
+ const fallback = cleaned || "app";
147
+ return /^\d/.test(fallback) ? `app${fallback}` : fallback;
148
+ }
149
+ async function replaceInTextFiles(dir, replacements) {
150
+ const files = await fs_extra_1.default.readdir(dir);
151
+ for (const file of files) {
152
+ const filePath = path_1.default.join(dir, file);
153
+ const stat = await fs_extra_1.default.stat(filePath);
154
+ if (stat.isDirectory()) {
155
+ await replaceInTextFiles(filePath, replacements);
156
+ continue;
157
+ }
158
+ if (!isTextFile(filePath))
159
+ continue;
160
+ let content = await fs_extra_1.default.readFile(filePath, "utf8");
161
+ for (const [pattern, replacement] of replacements) {
162
+ content = content.replace(pattern, replacement);
163
+ }
164
+ await fs_extra_1.default.writeFile(filePath, content, "utf8");
165
+ }
166
+ }
167
+ async function renameIfExists(fromPath, toPath) {
168
+ if (!(await fs_extra_1.default.pathExists(fromPath)))
169
+ return;
170
+ await fs_extra_1.default.move(fromPath, toPath, { overwrite: true });
171
+ }
172
+ async function configureCliNativeProjectNames(targetPath, projectName) {
173
+ const nativeAppName = toNativeAppName(projectName);
174
+ const androidPackageSegment = toAndroidPackageSegment(projectName);
175
+ await replaceInTextFiles(targetPath, [
176
+ [/BaseApp/g, nativeAppName],
177
+ [/com\.baseapp/g, `com.${androidPackageSegment}`],
178
+ ]);
179
+ const iosPath = path_1.default.join(targetPath, "ios");
180
+ const androidJavaRoot = path_1.default.join(targetPath, "android", "app", "src", "main", "java");
181
+ await renameIfExists(path_1.default.join(iosPath, "BaseApp"), path_1.default.join(iosPath, nativeAppName));
182
+ await renameIfExists(path_1.default.join(iosPath, "BaseApp.xcodeproj"), path_1.default.join(iosPath, `${nativeAppName}.xcodeproj`));
183
+ await renameIfExists(path_1.default.join(iosPath, "BaseApp.xcworkspace"), path_1.default.join(iosPath, `${nativeAppName}.xcworkspace`));
184
+ await renameIfExists(path_1.default.join(iosPath, `${nativeAppName}.xcodeproj`, "xcshareddata", "xcschemes", "BaseApp.xcscheme"), path_1.default.join(iosPath, `${nativeAppName}.xcodeproj`, "xcshareddata", "xcschemes", `${nativeAppName}.xcscheme`));
185
+ await renameIfExists(path_1.default.join(androidJavaRoot, "com", "baseapp"), path_1.default.join(androidJavaRoot, "com", androidPackageSegment));
186
+ }
105
187
  async function installDependencies(targetPath) {
188
+ if (process.env.SKIP_INSTALL === "1") {
189
+ console.log("⏭️ Skipping npm install (SKIP_INSTALL=1)");
190
+ return;
191
+ }
106
192
  console.log("📦 Installing dependencies...");
107
193
  try {
108
194
  await (0, execa_1.execa)("npm", ["install"], { cwd: targetPath, stdio: "inherit" });
109
195
  console.log("✅ Dependencies installed!");
110
196
  }
111
- catch (err) {
112
- console.log("⚠️ Failed to install dependencies. Run 'npm install' manually.");
197
+ catch {
198
+ console.log("⚠️ Peer dep conflict detected, retrying with --legacy-peer-deps...");
199
+ try {
200
+ await (0, execa_1.execa)("npm", ["install", "--legacy-peer-deps"], {
201
+ cwd: targetPath,
202
+ stdio: "inherit",
203
+ });
204
+ console.log("✅ Dependencies installed!");
205
+ }
206
+ catch {
207
+ console.log("⚠️ Failed to install dependencies. Run 'npm install --legacy-peer-deps' manually.");
208
+ }
113
209
  }
114
210
  }
115
211
  async function setAuthTabsByPlatform(targetPath, platform) {
@@ -151,7 +247,7 @@ async function ensureCliApiEnvSupport(targetPath) {
151
247
  },
152
248
  });
153
249
  }
154
- async function createStandardStructure(targetPath, platform) {
250
+ async function createStandardStructure(targetPath, platform, state) {
155
251
  const commonDirectories = [
156
252
  "assets",
157
253
  "assets/icons",
@@ -161,10 +257,19 @@ async function createStandardStructure(targetPath, platform) {
161
257
  "hooks",
162
258
  "utils",
163
259
  "services",
164
- "api",
165
260
  "config",
166
- "context",
261
+ "theme",
262
+ "types",
167
263
  ];
264
+ // context/ only when Context API is the state solution
265
+ if (state === "Context API" || state === "None") {
266
+ commonDirectories.push("context");
267
+ }
268
+ // api/ only when an API client or auth module will populate it
269
+ // (auth modules and apiClient copyOptionalModule both write into api/)
270
+ // — handled by those modules; we don't pre-create an empty dir here
271
+ // providers/ is NOT pre-created — writeIfMissing creates it on demand
272
+ // when SWR / React Query / i18n providers are written into it
168
273
  const cliOnlyDirectories = ["navigation", "screens"];
169
274
  const directories = platform === "Expo"
170
275
  ? commonDirectories
@@ -196,9 +301,22 @@ async function configureDataFetching(targetPath, dataFetching) {
196
301
  "@tanstack/react-query": "^5.90.5",
197
302
  },
198
303
  });
304
+ // queryClient.ts is provided by the react-query optional module template
305
+ // writeIfMissing ensures we don't overwrite it if already copied
199
306
  await writeIfMissing(path_1.default.join(targetPath, "services/queryClient.ts"), `import { QueryClient } from "@tanstack/react-query";
200
307
 
201
- export const queryClient = new QueryClient();
308
+ export const queryClient = new QueryClient({
309
+ defaultOptions: {
310
+ queries: {
311
+ retry: 2,
312
+ staleTime: 1000 * 60 * 5,
313
+ gcTime: 1000 * 60 * 10,
314
+ },
315
+ mutations: {
316
+ retry: 0,
317
+ },
318
+ },
319
+ });
202
320
  `);
203
321
  }
204
322
  if (dataFetching === "SWR") {
@@ -207,6 +325,28 @@ export const queryClient = new QueryClient();
207
325
  swr: "^2.3.6",
208
326
  },
209
327
  });
328
+ // SWRProvider is provided by the swr optional module template
329
+ // Add a fallback if template copy didn't work
330
+ await writeIfMissing(path_1.default.join(targetPath, "providers/SWRProvider.tsx"), `import React from "react";
331
+ import { SWRConfig } from "swr";
332
+
333
+ type Props = { children: React.ReactNode };
334
+
335
+ export function SWRProvider({ children }: Props) {
336
+ return (
337
+ <SWRConfig
338
+ value={{
339
+ revalidateOnFocus: true,
340
+ revalidateOnReconnect: true,
341
+ shouldRetryOnError: true,
342
+ errorRetryCount: 3,
343
+ }}
344
+ >
345
+ {children}
346
+ </SWRConfig>
347
+ );
348
+ }
349
+ `);
210
350
  }
211
351
  }
212
352
  async function configureApiClientTransport(targetPath, apiClient, apiClientType) {
@@ -377,7 +517,28 @@ async function configureValidation(targetPath, validation) {
377
517
  async function configureStateAndAuthDependencies(targetPath, options) {
378
518
  const dependencies = {};
379
519
  if (options.auth && options.platform === "React Native CLI") {
520
+ // Full navigation stack required for auth flow
521
+ dependencies["@react-navigation/native"] = "^7.1.8";
380
522
  dependencies["@react-navigation/native-stack"] = "^7.3.29";
523
+ dependencies["@react-navigation/bottom-tabs"] = "^7.4.0";
524
+ dependencies["react-native-screens"] = "^4.16.0";
525
+ dependencies["react-native-gesture-handler"] = "^2.28.0";
526
+ dependencies["react-native-worklets"] = CLI_WORKLETS_VERSION;
527
+ dependencies["react-native-reanimated"] = CLI_REANIMATED_VERSION;
528
+ }
529
+ // Expo with auth uses expo-router tabs which require reanimated
530
+ if (options.auth && options.platform === "Expo") {
531
+ dependencies["react-native-worklets"] = EXPO_WORKLETS_VERSION;
532
+ dependencies["react-native-reanimated"] = EXPO_REANIMATED_VERSION;
533
+ }
534
+ // AsyncStorage: needed when explicitly selected OR when auth is enabled
535
+ // (auth modules use AsyncStorage for welcome screen persistence)
536
+ if (options.storage === "AsyncStorage" || options.auth) {
537
+ dependencies["@react-native-async-storage/async-storage"] = "^2.2.0";
538
+ }
539
+ // MMKV: used for token storage when selected
540
+ if (options.storage === "MMKV") {
541
+ dependencies["react-native-mmkv"] = "^3.0.0";
381
542
  }
382
543
  if (options.state === "Redux Toolkit") {
383
544
  dependencies["@reduxjs/toolkit"] = "^2.9.0";
@@ -386,21 +547,17 @@ async function configureStateAndAuthDependencies(targetPath, options) {
386
547
  if (options.state === "Zustand") {
387
548
  dependencies.zustand = "^5.0.8";
388
549
  }
389
- if (options.storage === "AsyncStorage" &&
390
- (options.state === "Context API" || options.state === "Zustand")) {
391
- dependencies["@react-native-async-storage/async-storage"] = "^2.2.0";
392
- }
393
550
  if (Object.keys(dependencies).length > 0) {
394
551
  await ensureDependencies(targetPath, { dependencies });
395
552
  }
396
553
  }
397
- async function writeExpoRouterAuthRoutes(targetPath, state) {
554
+ async function writeExpoRouterAuthRoutes(targetPath, state, dataFetching = "None") {
398
555
  const appDir = path_1.default.join(targetPath, "app");
399
556
  const authDir = path_1.default.join(appDir, "(auth)");
400
557
  const tabsDir = path_1.default.join(appDir, "(tabs)");
401
558
  await fs_extra_1.default.ensureDir(authDir);
402
559
  await fs_extra_1.default.ensureDir(tabsDir);
403
- const stateBindings = getExpoAuthStateBindings(state);
560
+ const stateBindings = getExpoAuthStateBindings(state, dataFetching);
404
561
  const routeFiles = getExpoAuthRouteFiles(state);
405
562
  await fs_extra_1.default.writeFile(path_1.default.join(appDir, "_layout.tsx"), stateBindings.rootLayout, "utf8");
406
563
  await fs_extra_1.default.writeFile(path_1.default.join(appDir, "index.tsx"), stateBindings.indexRoute, "utf8");
@@ -408,35 +565,87 @@ async function writeExpoRouterAuthRoutes(targetPath, state) {
408
565
  await fs_extra_1.default.writeFile(path_1.default.join(tabsDir, "_layout.tsx"), stateBindings.tabsLayout, "utf8");
409
566
  await fs_extra_1.default.writeFile(path_1.default.join(authDir, "login.tsx"), routeFiles.login, "utf8");
410
567
  await fs_extra_1.default.writeFile(path_1.default.join(authDir, "register.tsx"), routeFiles.register, "utf8");
568
+ await fs_extra_1.default.writeFile(path_1.default.join(appDir, "welcome.tsx"), routeFiles.welcome, "utf8");
411
569
  await fs_extra_1.default.writeFile(path_1.default.join(tabsDir, "index.tsx"), routeFiles.home, "utf8");
412
570
  await fs_extra_1.default.writeFile(path_1.default.join(tabsDir, "profile.tsx"), routeFiles.profile, "utf8");
413
571
  await fs_extra_1.default.writeFile(path_1.default.join(tabsDir, "settings.tsx"), routeFiles.settings, "utf8");
414
572
  await fs_extra_1.default.remove(path_1.default.join(targetPath, "navigation"));
415
573
  await fs_extra_1.default.remove(path_1.default.join(targetPath, "screens"));
416
574
  }
417
- function getExpoAuthStateBindings(state) {
575
+ function getDataFetchingExpoImports(dataFetching) {
576
+ if (dataFetching === "React Query") {
577
+ return `import { QueryClientProvider } from "@tanstack/react-query";\nimport { queryClient } from "../services/queryClient";\n`;
578
+ }
579
+ if (dataFetching === "SWR") {
580
+ return `import { SWRConfig } from "swr";\n`;
581
+ }
582
+ return "";
583
+ }
584
+ function wrapExpoSlotWithDataFetching(inner, dataFetching) {
585
+ if (dataFetching === "React Query") {
586
+ return `<QueryClientProvider client={queryClient}>\n ${inner}\n </QueryClientProvider>`;
587
+ }
588
+ if (dataFetching === "SWR") {
589
+ return `<SWRConfig value={{ revalidateOnFocus: true, revalidateOnReconnect: true }}>\n ${inner}\n </SWRConfig>`;
590
+ }
591
+ return inner;
592
+ }
593
+ function getExpoAuthStateBindings(state, dataFetching = "None") {
594
+ const dfImports = getDataFetchingExpoImports(dataFetching);
418
595
  if (state === "Redux Toolkit") {
596
+ const slot = wrapExpoSlotWithDataFetching(`<Slot />`, dataFetching);
419
597
  return {
420
598
  rootLayout: `import React from "react";
421
599
  import { Slot } from "expo-router";
422
600
  import { Provider } from "react-redux";
423
- import { store } from "../store/store";
601
+ import { SafeAreaProvider } from "react-native-safe-area-context";
602
+ ${dfImports}import { store } from "../store/store";
424
603
 
425
604
  export default function RootLayout() {
426
605
  return (
427
- <Provider store={store}>
428
- <Slot />
429
- </Provider>
606
+ <SafeAreaProvider>
607
+ <Provider store={store}>
608
+ ${slot}
609
+ </Provider>
610
+ </SafeAreaProvider>
430
611
  );
431
612
  }
432
613
  `,
433
- indexRoute: `import React from "react";
614
+ indexRoute: `import React, { useEffect, useState } from "react";
615
+ import AsyncStorage from "@react-native-async-storage/async-storage";
616
+ import { ActivityIndicator } from "react-native";
434
617
  import { Redirect } from "expo-router";
435
618
  import { useSelector } from "react-redux";
619
+ import ScreenLayout from "../components/layout/ScreenLayout";
436
620
  import type { RootState } from "../store/store";
437
621
 
438
622
  export default function Index() {
439
623
  const token = useSelector((state: RootState) => state.auth.token);
624
+ const [isCheckingWelcome, setIsCheckingWelcome] = useState(true);
625
+ const [hasSeenWelcome, setHasSeenWelcome] = useState(false);
626
+
627
+ useEffect(() => {
628
+ const readWelcomeState = async () => {
629
+ try {
630
+ const seen = await AsyncStorage.getItem("@rnskit_has_seen_welcome");
631
+ setHasSeenWelcome(seen === "true");
632
+ } finally {
633
+ setIsCheckingWelcome(false);
634
+ }
635
+ };
636
+
637
+ void readWelcomeState();
638
+ }, []);
639
+
640
+ if (isCheckingWelcome) {
641
+ return (
642
+ <ScreenLayout centered padded={false}>
643
+ <ActivityIndicator />
644
+ </ScreenLayout>
645
+ );
646
+ }
647
+
648
+ if (!hasSeenWelcome) return <Redirect href="/welcome" />;
440
649
  return <Redirect href={token ? "/(tabs)" : "/(auth)/login"} />;
441
650
  }
442
651
  `,
@@ -478,43 +687,67 @@ export default function TabsLayout() {
478
687
  };
479
688
  }
480
689
  if (state === "Zustand") {
690
+ const slot = wrapExpoSlotWithDataFetching(`<Slot />`, dataFetching);
481
691
  return {
482
692
  rootLayout: `import React from "react";
483
693
  import { Slot } from "expo-router";
484
-
694
+ import { SafeAreaProvider } from "react-native-safe-area-context";
695
+ ${dfImports}
485
696
  export default function RootLayout() {
486
- return <Slot />;
697
+ return (
698
+ <SafeAreaProvider>
699
+ ${slot}
700
+ </SafeAreaProvider>
701
+ );
487
702
  }
488
703
  `,
489
- indexRoute: `import React from "react";
490
- import { useEffect } from "react";
491
- import { ActivityIndicator, View } from "react-native";
704
+ indexRoute: `import React, { useEffect, useState } from "react";
705
+ import AsyncStorage from "@react-native-async-storage/async-storage";
706
+ import { ActivityIndicator } from "react-native";
492
707
  import { Redirect } from "expo-router";
708
+ import ScreenLayout from "../components/layout/ScreenLayout";
493
709
  import { useAuthStore } from "../store/authStore";
494
710
 
495
711
  export default function Index() {
496
712
  const token = useAuthStore((state) => state.token);
497
713
  const isHydrated = useAuthStore((state) => state.isHydrated);
498
714
  const hydrate = useAuthStore((state) => state.hydrate);
715
+ const [isCheckingWelcome, setIsCheckingWelcome] = useState(true);
716
+ const [hasSeenWelcome, setHasSeenWelcome] = useState(false);
499
717
 
500
718
  useEffect(() => {
501
719
  void hydrate();
502
720
  }, [hydrate]);
503
721
 
504
- if (!isHydrated) {
722
+ useEffect(() => {
723
+ const readWelcomeState = async () => {
724
+ try {
725
+ const seen = await AsyncStorage.getItem("@rnskit_has_seen_welcome");
726
+ setHasSeenWelcome(seen === "true");
727
+ } finally {
728
+ setIsCheckingWelcome(false);
729
+ }
730
+ };
731
+
732
+ void readWelcomeState();
733
+ }, []);
734
+
735
+ if (!isHydrated || isCheckingWelcome) {
505
736
  return (
506
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
737
+ <ScreenLayout centered padded={false}>
507
738
  <ActivityIndicator />
508
- </View>
739
+ </ScreenLayout>
509
740
  );
510
741
  }
511
742
 
743
+ if (!hasSeenWelcome) return <Redirect href="/welcome" />;
512
744
  return <Redirect href={token ? "/(tabs)" : "/(auth)/login"} />;
513
745
  }
514
746
  `,
515
747
  authLayout: `import React, { useEffect } from "react";
516
- import { ActivityIndicator, View } from "react-native";
748
+ import { ActivityIndicator } from "react-native";
517
749
  import { Redirect, Stack } from "expo-router";
750
+ import ScreenLayout from "../../components/layout/ScreenLayout";
518
751
  import { useAuthStore } from "../../store/authStore";
519
752
 
520
753
  export default function AuthLayout() {
@@ -528,9 +761,9 @@ export default function AuthLayout() {
528
761
 
529
762
  if (!isHydrated) {
530
763
  return (
531
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
764
+ <ScreenLayout centered padded={false}>
532
765
  <ActivityIndicator />
533
- </View>
766
+ </ScreenLayout>
534
767
  );
535
768
  }
536
769
 
@@ -545,8 +778,9 @@ export default function AuthLayout() {
545
778
  }
546
779
  `,
547
780
  tabsLayout: `import React, { useEffect } from "react";
548
- import { ActivityIndicator, View } from "react-native";
781
+ import { ActivityIndicator } from "react-native";
549
782
  import { Redirect, Tabs } from "expo-router";
783
+ import ScreenLayout from "../../components/layout/ScreenLayout";
550
784
  import { useAuthStore } from "../../store/authStore";
551
785
 
552
786
  export default function TabsLayout() {
@@ -560,9 +794,9 @@ export default function TabsLayout() {
560
794
 
561
795
  if (!isHydrated) {
562
796
  return (
563
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
797
+ <ScreenLayout centered padded={false}>
564
798
  <ActivityIndicator />
565
- </View>
799
+ </ScreenLayout>
566
800
  );
567
801
  }
568
802
 
@@ -579,41 +813,64 @@ export default function TabsLayout() {
579
813
  `,
580
814
  };
581
815
  }
816
+ const slot = wrapExpoSlotWithDataFetching(`<Slot />`, dataFetching);
582
817
  return {
583
818
  rootLayout: `import React from "react";
584
819
  import { Slot } from "expo-router";
585
- import { AuthProvider } from "../context/AuthContext";
820
+ import { SafeAreaProvider } from "react-native-safe-area-context";
821
+ ${dfImports}import { AuthProvider } from "../context/AuthContext";
586
822
 
587
823
  export default function RootLayout() {
588
824
  return (
589
- <AuthProvider>
590
- <Slot />
591
- </AuthProvider>
825
+ <SafeAreaProvider>
826
+ <AuthProvider>
827
+ ${slot}
828
+ </AuthProvider>
829
+ </SafeAreaProvider>
592
830
  );
593
831
  }
594
832
  `,
595
- indexRoute: `import React, { useContext } from "react";
596
- import { ActivityIndicator, View } from "react-native";
833
+ indexRoute: `import React, { useContext, useEffect, useState } from "react";
834
+ import AsyncStorage from "@react-native-async-storage/async-storage";
835
+ import { ActivityIndicator } from "react-native";
597
836
  import { Redirect } from "expo-router";
837
+ import ScreenLayout from "../components/layout/ScreenLayout";
598
838
  import { AuthContext } from "../context/AuthContext";
599
839
 
600
840
  export default function Index() {
601
841
  const { token, isHydrated } = useContext(AuthContext);
842
+ const [isCheckingWelcome, setIsCheckingWelcome] = useState(true);
843
+ const [hasSeenWelcome, setHasSeenWelcome] = useState(false);
602
844
 
603
- if (!isHydrated) {
845
+ useEffect(() => {
846
+ const readWelcomeState = async () => {
847
+ try {
848
+ const seen = await AsyncStorage.getItem("@rnskit_has_seen_welcome");
849
+ setHasSeenWelcome(seen === "true");
850
+ } finally {
851
+ setIsCheckingWelcome(false);
852
+ }
853
+ };
854
+
855
+ void readWelcomeState();
856
+ }, []);
857
+
858
+ if (!isHydrated || isCheckingWelcome) {
604
859
  return (
605
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
860
+ <ScreenLayout centered padded={false}>
606
861
  <ActivityIndicator />
607
- </View>
862
+ </ScreenLayout>
608
863
  );
609
864
  }
610
865
 
866
+ if (!hasSeenWelcome) return <Redirect href="/welcome" />;
611
867
  return <Redirect href={token ? "/(tabs)" : "/(auth)/login"} />;
612
868
  }
613
869
  `,
614
870
  authLayout: `import React, { useContext } from "react";
615
- import { ActivityIndicator, View } from "react-native";
871
+ import { ActivityIndicator } from "react-native";
616
872
  import { Redirect, Stack } from "expo-router";
873
+ import ScreenLayout from "../../components/layout/ScreenLayout";
617
874
  import { AuthContext } from "../../context/AuthContext";
618
875
 
619
876
  export default function AuthLayout() {
@@ -621,9 +878,9 @@ export default function AuthLayout() {
621
878
 
622
879
  if (!isHydrated) {
623
880
  return (
624
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
881
+ <ScreenLayout centered padded={false}>
625
882
  <ActivityIndicator />
626
- </View>
883
+ </ScreenLayout>
627
884
  );
628
885
  }
629
886
 
@@ -638,8 +895,9 @@ export default function AuthLayout() {
638
895
  }
639
896
  `,
640
897
  tabsLayout: `import React, { useContext } from "react";
641
- import { ActivityIndicator, View } from "react-native";
898
+ import { ActivityIndicator } from "react-native";
642
899
  import { Redirect, Tabs } from "expo-router";
900
+ import ScreenLayout from "../../components/layout/ScreenLayout";
643
901
  import { AuthContext } from "../../context/AuthContext";
644
902
 
645
903
  export default function TabsLayout() {
@@ -647,9 +905,9 @@ export default function TabsLayout() {
647
905
 
648
906
  if (!isHydrated) {
649
907
  return (
650
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
908
+ <ScreenLayout centered padded={false}>
651
909
  <ActivityIndicator />
652
- </View>
910
+ </ScreenLayout>
653
911
  );
654
912
  }
655
913
 
@@ -667,43 +925,784 @@ export default function TabsLayout() {
667
925
  };
668
926
  }
669
927
  function getExpoAuthRouteFiles(state) {
670
- const register = `import React from "react";
671
- import { Text, View } from "react-native";
928
+ const welcomeRedux = `import React, { useRef, useState } from "react";
929
+ import AsyncStorage from "@react-native-async-storage/async-storage";
930
+ import {
931
+ FlatList,
932
+ Image,
933
+ ImageSourcePropType,
934
+ Pressable,
935
+ StyleSheet,
936
+ Text,
937
+ useWindowDimensions,
938
+ View,
939
+ ViewToken,
940
+ } from "react-native";
941
+ import { router } from "expo-router";
942
+ import { SafeAreaView } from "react-native-safe-area-context";
943
+ import { useSelector } from "react-redux";
944
+ import type { RootState } from "../store/store";
945
+
946
+ type Slide = {
947
+ id: string;
948
+ title: string;
949
+ description: string;
950
+ image: ImageSourcePropType;
951
+ };
952
+
953
+ const slides: Slide[] = [
954
+ {
955
+ id: "1",
956
+ title: "Welcome to RN Starter Kit",
957
+ description: "Build faster with a clean, production-ready app foundation.",
958
+ image: require("../assets/images/react-logo.png"),
959
+ },
960
+ {
961
+ id: "2",
962
+ title: "Structure That Scales",
963
+ description: "Auth, state, and API setup are organized for real-world apps.",
964
+ image: require("../assets/images/partial-react-logo.png"),
965
+ },
966
+ {
967
+ id: "3",
968
+ title: "Make It Yours",
969
+ description: "Swap this welcome flow with your own brand and product journey.",
970
+ image: require("../assets/images/icon.png"),
971
+ },
972
+ ];
973
+
974
+ export default function WelcomeScreen() {
975
+ const token = useSelector((state: RootState) => state.auth.token);
976
+ const { width } = useWindowDimensions();
977
+ const [activeIndex, setActiveIndex] = useState(0);
978
+ const listRef = useRef<FlatList<Slide>>(null);
979
+
980
+ const onViewableItemsChanged = useRef(
981
+ ({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
982
+ if (viewableItems[0]?.index != null) {
983
+ setActiveIndex(viewableItems[0].index);
984
+ }
985
+ },
986
+ ).current;
987
+
988
+ const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 }).current;
989
+
990
+ const handleNext = async () => {
991
+ const nextIndex = activeIndex + 1;
992
+ if (nextIndex < slides.length) {
993
+ listRef.current?.scrollToIndex({ index: nextIndex, animated: true });
994
+ return;
995
+ }
996
+ await AsyncStorage.setItem("@rnskit_has_seen_welcome", "true");
997
+ router.replace(token ? "/(tabs)" : "/(auth)/login");
998
+ };
999
+
1000
+ const isLastSlide = activeIndex === slides.length - 1;
1001
+
1002
+ return (
1003
+ <SafeAreaView style={styles.safeArea}>
1004
+ <FlatList
1005
+ ref={listRef}
1006
+ data={slides}
1007
+ horizontal
1008
+ pagingEnabled
1009
+ bounces={false}
1010
+ keyExtractor={(item) => item.id}
1011
+ showsHorizontalScrollIndicator={false}
1012
+ renderItem={({ item }) => (
1013
+ <View style={[styles.slide, { width }]}>
1014
+ <Image source={item.image} style={styles.image} resizeMode="contain" />
1015
+ <Text style={styles.title}>{item.title}</Text>
1016
+ <Text style={styles.description}>{item.description}</Text>
1017
+ </View>
1018
+ )}
1019
+ onViewableItemsChanged={onViewableItemsChanged}
1020
+ viewabilityConfig={viewabilityConfig}
1021
+ />
1022
+
1023
+ <View style={styles.footer}>
1024
+ <View style={styles.dotsRow}>
1025
+ {slides.map((slide, index) => (
1026
+ <View
1027
+ key={slide.id}
1028
+ style={[styles.dot, index === activeIndex && styles.dotActive]}
1029
+ />
1030
+ ))}
1031
+ </View>
1032
+
1033
+ <Pressable style={styles.button} onPress={() => void handleNext()}>
1034
+ <Text style={styles.buttonLabel}>{isLastSlide ? "Get Started" : "Next"}</Text>
1035
+ </Pressable>
1036
+ </View>
1037
+ </SafeAreaView>
1038
+ );
1039
+ }
1040
+
1041
+ const styles = StyleSheet.create({
1042
+ safeArea: {
1043
+ flex: 1,
1044
+ backgroundColor: "#F7F7F9",
1045
+ },
1046
+ slide: {
1047
+ flex: 1,
1048
+ paddingHorizontal: 24,
1049
+ justifyContent: "center",
1050
+ alignItems: "center",
1051
+ },
1052
+ image: {
1053
+ width: 220,
1054
+ height: 220,
1055
+ marginBottom: 28,
1056
+ },
1057
+ title: {
1058
+ fontSize: 28,
1059
+ fontWeight: "700",
1060
+ color: "#111827",
1061
+ textAlign: "center",
1062
+ marginBottom: 12,
1063
+ },
1064
+ description: {
1065
+ fontSize: 16,
1066
+ lineHeight: 24,
1067
+ color: "#4B5563",
1068
+ textAlign: "center",
1069
+ maxWidth: 320,
1070
+ },
1071
+ footer: {
1072
+ paddingHorizontal: 24,
1073
+ paddingBottom: 28,
1074
+ gap: 20,
1075
+ },
1076
+ dotsRow: {
1077
+ flexDirection: "row",
1078
+ justifyContent: "center",
1079
+ gap: 8,
1080
+ },
1081
+ dot: {
1082
+ width: 8,
1083
+ height: 8,
1084
+ borderRadius: 999,
1085
+ backgroundColor: "#D1D5DB",
1086
+ },
1087
+ dotActive: {
1088
+ width: 24,
1089
+ backgroundColor: "#111827",
1090
+ },
1091
+ button: {
1092
+ height: 52,
1093
+ borderRadius: 12,
1094
+ backgroundColor: "#111827",
1095
+ alignItems: "center",
1096
+ justifyContent: "center",
1097
+ },
1098
+ buttonLabel: {
1099
+ color: "#FFFFFF",
1100
+ fontSize: 16,
1101
+ fontWeight: "600",
1102
+ },
1103
+ });
1104
+ `;
1105
+ const welcomeZustand = `import React, { useEffect, useRef, useState } from "react";
1106
+ import AsyncStorage from "@react-native-async-storage/async-storage";
1107
+ import {
1108
+ ActivityIndicator,
1109
+ FlatList,
1110
+ Image,
1111
+ ImageSourcePropType,
1112
+ Pressable,
1113
+ StyleSheet,
1114
+ Text,
1115
+ useWindowDimensions,
1116
+ View,
1117
+ ViewToken,
1118
+ } from "react-native";
1119
+ import { router } from "expo-router";
1120
+ import { SafeAreaView } from "react-native-safe-area-context";
1121
+ import { useAuthStore } from "../store/authStore";
1122
+ import ScreenLayout from "../components/layout/ScreenLayout";
1123
+
1124
+ type Slide = {
1125
+ id: string;
1126
+ title: string;
1127
+ description: string;
1128
+ image: ImageSourcePropType;
1129
+ };
1130
+
1131
+ const slides: Slide[] = [
1132
+ {
1133
+ id: "1",
1134
+ title: "Welcome to RN Starter Kit",
1135
+ description: "Build faster with a clean, production-ready app foundation.",
1136
+ image: require("../assets/images/react-logo.png"),
1137
+ },
1138
+ {
1139
+ id: "2",
1140
+ title: "Structure That Scales",
1141
+ description: "Auth, state, and API setup are organized for real-world apps.",
1142
+ image: require("../assets/images/partial-react-logo.png"),
1143
+ },
1144
+ {
1145
+ id: "3",
1146
+ title: "Make It Yours",
1147
+ description: "Swap this welcome flow with your own brand and product journey.",
1148
+ image: require("../assets/images/icon.png"),
1149
+ },
1150
+ ];
1151
+
1152
+ export default function WelcomeScreen() {
1153
+ const token = useAuthStore((state) => state.token);
1154
+ const isHydrated = useAuthStore((state) => state.isHydrated);
1155
+ const hydrate = useAuthStore((state) => state.hydrate);
1156
+ const { width } = useWindowDimensions();
1157
+ const [activeIndex, setActiveIndex] = useState(0);
1158
+ const listRef = useRef<FlatList<Slide>>(null);
1159
+
1160
+ useEffect(() => {
1161
+ void hydrate();
1162
+ }, [hydrate]);
1163
+
1164
+ const onViewableItemsChanged = useRef(
1165
+ ({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
1166
+ if (viewableItems[0]?.index != null) {
1167
+ setActiveIndex(viewableItems[0].index);
1168
+ }
1169
+ },
1170
+ ).current;
1171
+
1172
+ const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 }).current;
1173
+
1174
+ const handleNext = async () => {
1175
+ const nextIndex = activeIndex + 1;
1176
+ if (nextIndex < slides.length) {
1177
+ listRef.current?.scrollToIndex({ index: nextIndex, animated: true });
1178
+ return;
1179
+ }
1180
+ await AsyncStorage.setItem("@rnskit_has_seen_welcome", "true");
1181
+ router.replace(token ? "/(tabs)" : "/(auth)/login");
1182
+ };
1183
+
1184
+ if (!isHydrated) {
1185
+ return (
1186
+ <ScreenLayout centered padded={false}>
1187
+ <ActivityIndicator />
1188
+ </ScreenLayout>
1189
+ );
1190
+ }
1191
+
1192
+ const isLastSlide = activeIndex === slides.length - 1;
1193
+
1194
+ return (
1195
+ <SafeAreaView style={styles.safeArea}>
1196
+ <FlatList
1197
+ ref={listRef}
1198
+ data={slides}
1199
+ horizontal
1200
+ pagingEnabled
1201
+ bounces={false}
1202
+ keyExtractor={(item) => item.id}
1203
+ showsHorizontalScrollIndicator={false}
1204
+ renderItem={({ item }) => (
1205
+ <View style={[styles.slide, { width }]}>
1206
+ <Image source={item.image} style={styles.image} resizeMode="contain" />
1207
+ <Text style={styles.title}>{item.title}</Text>
1208
+ <Text style={styles.description}>{item.description}</Text>
1209
+ </View>
1210
+ )}
1211
+ onViewableItemsChanged={onViewableItemsChanged}
1212
+ viewabilityConfig={viewabilityConfig}
1213
+ />
1214
+
1215
+ <View style={styles.footer}>
1216
+ <View style={styles.dotsRow}>
1217
+ {slides.map((slide, index) => (
1218
+ <View
1219
+ key={slide.id}
1220
+ style={[styles.dot, index === activeIndex && styles.dotActive]}
1221
+ />
1222
+ ))}
1223
+ </View>
1224
+
1225
+ <Pressable style={styles.button} onPress={() => void handleNext()}>
1226
+ <Text style={styles.buttonLabel}>{isLastSlide ? "Get Started" : "Next"}</Text>
1227
+ </Pressable>
1228
+ </View>
1229
+ </SafeAreaView>
1230
+ );
1231
+ }
1232
+
1233
+ const styles = StyleSheet.create({
1234
+ safeArea: {
1235
+ flex: 1,
1236
+ backgroundColor: "#F7F7F9",
1237
+ },
1238
+ slide: {
1239
+ flex: 1,
1240
+ paddingHorizontal: 24,
1241
+ justifyContent: "center",
1242
+ alignItems: "center",
1243
+ },
1244
+ image: {
1245
+ width: 220,
1246
+ height: 220,
1247
+ marginBottom: 28,
1248
+ },
1249
+ title: {
1250
+ fontSize: 28,
1251
+ fontWeight: "700",
1252
+ color: "#111827",
1253
+ textAlign: "center",
1254
+ marginBottom: 12,
1255
+ },
1256
+ description: {
1257
+ fontSize: 16,
1258
+ lineHeight: 24,
1259
+ color: "#4B5563",
1260
+ textAlign: "center",
1261
+ maxWidth: 320,
1262
+ },
1263
+ footer: {
1264
+ paddingHorizontal: 24,
1265
+ paddingBottom: 28,
1266
+ gap: 20,
1267
+ },
1268
+ dotsRow: {
1269
+ flexDirection: "row",
1270
+ justifyContent: "center",
1271
+ gap: 8,
1272
+ },
1273
+ dot: {
1274
+ width: 8,
1275
+ height: 8,
1276
+ borderRadius: 999,
1277
+ backgroundColor: "#D1D5DB",
1278
+ },
1279
+ dotActive: {
1280
+ width: 24,
1281
+ backgroundColor: "#111827",
1282
+ },
1283
+ button: {
1284
+ height: 52,
1285
+ borderRadius: 12,
1286
+ backgroundColor: "#111827",
1287
+ alignItems: "center",
1288
+ justifyContent: "center",
1289
+ },
1290
+ buttonLabel: {
1291
+ color: "#FFFFFF",
1292
+ fontSize: 16,
1293
+ fontWeight: "600",
1294
+ },
1295
+ });
1296
+ `;
1297
+ const welcomeContext = `import React, { useContext, useRef, useState } from "react";
1298
+ import AsyncStorage from "@react-native-async-storage/async-storage";
1299
+ import {
1300
+ ActivityIndicator,
1301
+ FlatList,
1302
+ Image,
1303
+ ImageSourcePropType,
1304
+ Pressable,
1305
+ StyleSheet,
1306
+ Text,
1307
+ useWindowDimensions,
1308
+ View,
1309
+ ViewToken,
1310
+ } from "react-native";
1311
+ import { router } from "expo-router";
1312
+ import { SafeAreaView } from "react-native-safe-area-context";
1313
+ import ScreenLayout from "../components/layout/ScreenLayout";
1314
+ import { AuthContext } from "../context/AuthContext";
1315
+
1316
+ type Slide = {
1317
+ id: string;
1318
+ title: string;
1319
+ description: string;
1320
+ image: ImageSourcePropType;
1321
+ };
1322
+
1323
+ const slides: Slide[] = [
1324
+ {
1325
+ id: "1",
1326
+ title: "Welcome to RN Starter Kit",
1327
+ description: "Build faster with a clean, production-ready app foundation.",
1328
+ image: require("../assets/images/react-logo.png"),
1329
+ },
1330
+ {
1331
+ id: "2",
1332
+ title: "Structure That Scales",
1333
+ description: "Auth, state, and API setup are organized for real-world apps.",
1334
+ image: require("../assets/images/partial-react-logo.png"),
1335
+ },
1336
+ {
1337
+ id: "3",
1338
+ title: "Make It Yours",
1339
+ description: "Swap this welcome flow with your own brand and product journey.",
1340
+ image: require("../assets/images/icon.png"),
1341
+ },
1342
+ ];
1343
+
1344
+ export default function WelcomeScreen() {
1345
+ const { token, isHydrated } = useContext(AuthContext);
1346
+ const { width } = useWindowDimensions();
1347
+ const [activeIndex, setActiveIndex] = useState(0);
1348
+ const listRef = useRef<FlatList<Slide>>(null);
1349
+
1350
+ const onViewableItemsChanged = useRef(
1351
+ ({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
1352
+ if (viewableItems[0]?.index != null) {
1353
+ setActiveIndex(viewableItems[0].index);
1354
+ }
1355
+ },
1356
+ ).current;
1357
+
1358
+ const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 }).current;
1359
+
1360
+ const handleNext = async () => {
1361
+ const nextIndex = activeIndex + 1;
1362
+ if (nextIndex < slides.length) {
1363
+ listRef.current?.scrollToIndex({ index: nextIndex, animated: true });
1364
+ return;
1365
+ }
1366
+ await AsyncStorage.setItem("@rnskit_has_seen_welcome", "true");
1367
+ router.replace(token ? "/(tabs)" : "/(auth)/login");
1368
+ };
1369
+
1370
+ if (!isHydrated) {
1371
+ return (
1372
+ <ScreenLayout centered padded={false}>
1373
+ <ActivityIndicator />
1374
+ </ScreenLayout>
1375
+ );
1376
+ }
1377
+
1378
+ const isLastSlide = activeIndex === slides.length - 1;
1379
+
1380
+ return (
1381
+ <SafeAreaView style={styles.safeArea}>
1382
+ <FlatList
1383
+ ref={listRef}
1384
+ data={slides}
1385
+ horizontal
1386
+ pagingEnabled
1387
+ bounces={false}
1388
+ keyExtractor={(item) => item.id}
1389
+ showsHorizontalScrollIndicator={false}
1390
+ renderItem={({ item }) => (
1391
+ <View style={[styles.slide, { width }]}>
1392
+ <Image source={item.image} style={styles.image} resizeMode="contain" />
1393
+ <Text style={styles.title}>{item.title}</Text>
1394
+ <Text style={styles.description}>{item.description}</Text>
1395
+ </View>
1396
+ )}
1397
+ onViewableItemsChanged={onViewableItemsChanged}
1398
+ viewabilityConfig={viewabilityConfig}
1399
+ />
1400
+
1401
+ <View style={styles.footer}>
1402
+ <View style={styles.dotsRow}>
1403
+ {slides.map((slide, index) => (
1404
+ <View
1405
+ key={slide.id}
1406
+ style={[styles.dot, index === activeIndex && styles.dotActive]}
1407
+ />
1408
+ ))}
1409
+ </View>
1410
+
1411
+ <Pressable style={styles.button} onPress={() => void handleNext()}>
1412
+ <Text style={styles.buttonLabel}>{isLastSlide ? "Get Started" : "Next"}</Text>
1413
+ </Pressable>
1414
+ </View>
1415
+ </SafeAreaView>
1416
+ );
1417
+ }
1418
+
1419
+ const styles = StyleSheet.create({
1420
+ safeArea: {
1421
+ flex: 1,
1422
+ backgroundColor: "#F7F7F9",
1423
+ },
1424
+ slide: {
1425
+ flex: 1,
1426
+ paddingHorizontal: 24,
1427
+ justifyContent: "center",
1428
+ alignItems: "center",
1429
+ },
1430
+ image: {
1431
+ width: 220,
1432
+ height: 220,
1433
+ marginBottom: 28,
1434
+ },
1435
+ title: {
1436
+ fontSize: 28,
1437
+ fontWeight: "700",
1438
+ color: "#111827",
1439
+ textAlign: "center",
1440
+ marginBottom: 12,
1441
+ },
1442
+ description: {
1443
+ fontSize: 16,
1444
+ lineHeight: 24,
1445
+ color: "#4B5563",
1446
+ textAlign: "center",
1447
+ maxWidth: 320,
1448
+ },
1449
+ footer: {
1450
+ paddingHorizontal: 24,
1451
+ paddingBottom: 28,
1452
+ gap: 20,
1453
+ },
1454
+ dotsRow: {
1455
+ flexDirection: "row",
1456
+ justifyContent: "center",
1457
+ gap: 8,
1458
+ },
1459
+ dot: {
1460
+ width: 8,
1461
+ height: 8,
1462
+ borderRadius: 999,
1463
+ backgroundColor: "#D1D5DB",
1464
+ },
1465
+ dotActive: {
1466
+ width: 24,
1467
+ backgroundColor: "#111827",
1468
+ },
1469
+ button: {
1470
+ height: 52,
1471
+ borderRadius: 12,
1472
+ backgroundColor: "#111827",
1473
+ alignItems: "center",
1474
+ justifyContent: "center",
1475
+ },
1476
+ buttonLabel: {
1477
+ color: "#FFFFFF",
1478
+ fontSize: 16,
1479
+ fontWeight: "600",
1480
+ },
1481
+ });
1482
+ `;
1483
+ const registerRedux = `import React, { useState } from "react";
1484
+ import { Button, Text, TextInput } from "react-native";
1485
+ import ScreenLayout from "../../components/layout/ScreenLayout";
1486
+ import { useDispatch } from "react-redux";
1487
+ import { registerApi } from "../../api";
1488
+ import { login } from "../../store/authSlice";
1489
+
1490
+ export default function RegisterScreen() {
1491
+ const dispatch = useDispatch();
1492
+ const [email, setEmail] = useState("");
1493
+ const [password, setPassword] = useState("");
1494
+ const [error, setError] = useState("");
1495
+
1496
+ const handleRegister = async () => {
1497
+ try {
1498
+ setError("");
1499
+ const token = await registerApi(email, password);
1500
+ dispatch(login(token));
1501
+ } catch (err) {
1502
+ setError(err instanceof Error ? err.message : "Registration failed");
1503
+ }
1504
+ };
1505
+
1506
+ return (
1507
+ <ScreenLayout>
1508
+ <TextInput
1509
+ placeholder="Email"
1510
+ value={email}
1511
+ onChangeText={setEmail}
1512
+ autoCapitalize="none"
1513
+ autoCorrect={false}
1514
+ keyboardType="email-address"
1515
+ placeholderTextColor="#888"
1516
+ style={{
1517
+ borderWidth: 1,
1518
+ borderColor: "#ccc",
1519
+ borderRadius: 8,
1520
+ paddingHorizontal: 12,
1521
+ paddingVertical: 10,
1522
+ color: "#111",
1523
+ marginBottom: 12,
1524
+ }}
1525
+ />
1526
+ <TextInput
1527
+ placeholder="Password"
1528
+ value={password}
1529
+ onChangeText={setPassword}
1530
+ secureTextEntry
1531
+ placeholderTextColor="#888"
1532
+ style={{
1533
+ borderWidth: 1,
1534
+ borderColor: "#ccc",
1535
+ borderRadius: 8,
1536
+ paddingHorizontal: 12,
1537
+ paddingVertical: 10,
1538
+ color: "#111",
1539
+ marginBottom: 12,
1540
+ }}
1541
+ />
1542
+ {!!error && <Text style={{ color: "red", marginBottom: 8 }}>{error}</Text>}
1543
+ <Button title="Register" onPress={() => void handleRegister()} />
1544
+ </ScreenLayout>
1545
+ );
1546
+ }
1547
+ `;
1548
+ const registerZustand = `import React, { useState } from "react";
1549
+ import { Button, Text, TextInput } from "react-native";
1550
+ import ScreenLayout from "../../components/layout/ScreenLayout";
1551
+ import { registerApi } from "../../api";
1552
+ import { useAuthStore } from "../../store/authStore";
672
1553
 
673
1554
  export default function RegisterScreen() {
1555
+ const login = useAuthStore((state) => state.login);
1556
+ const [email, setEmail] = useState("");
1557
+ const [password, setPassword] = useState("");
1558
+ const [error, setError] = useState("");
1559
+
1560
+ const handleRegister = async () => {
1561
+ try {
1562
+ setError("");
1563
+ const token = await registerApi(email, password);
1564
+ await login(token);
1565
+ } catch (err) {
1566
+ setError(err instanceof Error ? err.message : "Registration failed");
1567
+ }
1568
+ };
1569
+
674
1570
  return (
675
- <View style={{ padding: 20 }}>
676
- <Text>RegisterScreen</Text>
677
- </View>
1571
+ <ScreenLayout>
1572
+ <TextInput
1573
+ placeholder="Email"
1574
+ value={email}
1575
+ onChangeText={setEmail}
1576
+ autoCapitalize="none"
1577
+ autoCorrect={false}
1578
+ keyboardType="email-address"
1579
+ placeholderTextColor="#888"
1580
+ style={{
1581
+ borderWidth: 1,
1582
+ borderColor: "#ccc",
1583
+ borderRadius: 8,
1584
+ paddingHorizontal: 12,
1585
+ paddingVertical: 10,
1586
+ color: "#111",
1587
+ marginBottom: 12,
1588
+ }}
1589
+ />
1590
+ <TextInput
1591
+ placeholder="Password"
1592
+ value={password}
1593
+ onChangeText={setPassword}
1594
+ secureTextEntry
1595
+ placeholderTextColor="#888"
1596
+ style={{
1597
+ borderWidth: 1,
1598
+ borderColor: "#ccc",
1599
+ borderRadius: 8,
1600
+ paddingHorizontal: 12,
1601
+ paddingVertical: 10,
1602
+ color: "#111",
1603
+ marginBottom: 12,
1604
+ }}
1605
+ />
1606
+ {!!error && <Text style={{ color: "red", marginBottom: 8 }}>{error}</Text>}
1607
+ <Button title="Register" onPress={() => void handleRegister()} />
1608
+ </ScreenLayout>
1609
+ );
1610
+ }
1611
+ `;
1612
+ const registerContext = `import React, { useContext, useState } from "react";
1613
+ import { Button, Text, TextInput } from "react-native";
1614
+ import ScreenLayout from "../../components/layout/ScreenLayout";
1615
+ import { registerApi } from "../../api";
1616
+ import { AuthContext } from "../../context/AuthContext";
1617
+
1618
+ export default function RegisterScreen() {
1619
+ const { login } = useContext(AuthContext);
1620
+ const [email, setEmail] = useState("");
1621
+ const [password, setPassword] = useState("");
1622
+ const [error, setError] = useState("");
1623
+
1624
+ const handleRegister = async () => {
1625
+ try {
1626
+ setError("");
1627
+ const token = await registerApi(email, password);
1628
+ await login(token);
1629
+ } catch (err) {
1630
+ setError(err instanceof Error ? err.message : "Registration failed");
1631
+ }
1632
+ };
1633
+
1634
+ return (
1635
+ <ScreenLayout>
1636
+ <TextInput
1637
+ placeholder="Email"
1638
+ value={email}
1639
+ onChangeText={setEmail}
1640
+ autoCapitalize="none"
1641
+ autoCorrect={false}
1642
+ keyboardType="email-address"
1643
+ placeholderTextColor="#888"
1644
+ style={{
1645
+ borderWidth: 1,
1646
+ borderColor: "#ccc",
1647
+ borderRadius: 8,
1648
+ paddingHorizontal: 12,
1649
+ paddingVertical: 10,
1650
+ color: "#111",
1651
+ marginBottom: 12,
1652
+ }}
1653
+ />
1654
+ <TextInput
1655
+ placeholder="Password"
1656
+ value={password}
1657
+ onChangeText={setPassword}
1658
+ secureTextEntry
1659
+ placeholderTextColor="#888"
1660
+ style={{
1661
+ borderWidth: 1,
1662
+ borderColor: "#ccc",
1663
+ borderRadius: 8,
1664
+ paddingHorizontal: 12,
1665
+ paddingVertical: 10,
1666
+ color: "#111",
1667
+ marginBottom: 12,
1668
+ }}
1669
+ />
1670
+ {!!error && <Text style={{ color: "red", marginBottom: 8 }}>{error}</Text>}
1671
+ <Button title="Register" onPress={() => void handleRegister()} />
1672
+ </ScreenLayout>
678
1673
  );
679
1674
  }
680
1675
  `;
681
1676
  const profile = `import React from "react";
682
- import { Text, View } from "react-native";
1677
+ import { Text } from "react-native";
1678
+ import ScreenLayout from "../../components/layout/ScreenLayout";
683
1679
 
684
1680
  export default function ProfileScreen() {
685
1681
  return (
686
- <View style={{ padding: 20 }}>
1682
+ <ScreenLayout>
687
1683
  <Text>ProfileScreen</Text>
688
- </View>
1684
+ </ScreenLayout>
689
1685
  );
690
1686
  }
691
1687
  `;
692
1688
  const settings = `import React from "react";
693
- import { Text, View } from "react-native";
1689
+ import { Text } from "react-native";
1690
+ import ScreenLayout from "../../components/layout/ScreenLayout";
694
1691
 
695
1692
  export default function SettingsScreen() {
696
1693
  return (
697
- <View style={{ padding: 20 }}>
1694
+ <ScreenLayout>
698
1695
  <Text>SettingsScreen</Text>
699
- </View>
1696
+ </ScreenLayout>
700
1697
  );
701
1698
  }
702
1699
  `;
703
1700
  if (state === "Redux Toolkit") {
704
1701
  return {
1702
+ welcome: welcomeRedux,
705
1703
  login: `import React, { useState } from "react";
706
- import { Button, TextInput, View } from "react-native";
1704
+ import { Button, Text, TextInput } from "react-native";
1705
+ import ScreenLayout from "../../components/layout/ScreenLayout";
707
1706
  import { useDispatch } from "react-redux";
708
1707
  import { loginApi } from "../../api";
709
1708
  import { login } from "../../store/authSlice";
@@ -712,29 +1711,64 @@ export default function LoginScreen() {
712
1711
  const dispatch = useDispatch();
713
1712
  const [email, setEmail] = useState("");
714
1713
  const [password, setPassword] = useState("");
1714
+ const [error, setError] = useState("");
715
1715
 
716
1716
  const handleLogin = async () => {
717
- const token = await loginApi(email, password);
718
- dispatch(login(token));
1717
+ try {
1718
+ setError("");
1719
+ const token = await loginApi(email, password);
1720
+ dispatch(login(token));
1721
+ } catch (err) {
1722
+ setError(err instanceof Error ? err.message : "Login failed");
1723
+ }
719
1724
  };
720
1725
 
721
1726
  return (
722
- <View style={{ padding: 20 }}>
723
- <TextInput placeholder="Email" value={email} onChangeText={setEmail} />
1727
+ <ScreenLayout>
1728
+ <TextInput
1729
+ placeholder="Email"
1730
+ value={email}
1731
+ onChangeText={setEmail}
1732
+ autoCapitalize="none"
1733
+ autoCorrect={false}
1734
+ keyboardType="email-address"
1735
+ placeholderTextColor="#888"
1736
+ style={{
1737
+ borderWidth: 1,
1738
+ borderColor: "#ccc",
1739
+ borderRadius: 8,
1740
+ paddingHorizontal: 12,
1741
+ paddingVertical: 10,
1742
+ color: "#111",
1743
+ marginBottom: 12,
1744
+ }}
1745
+ />
724
1746
  <TextInput
725
1747
  placeholder="Password"
726
1748
  value={password}
727
1749
  onChangeText={setPassword}
728
1750
  secureTextEntry
1751
+ placeholderTextColor="#888"
1752
+ style={{
1753
+ borderWidth: 1,
1754
+ borderColor: "#ccc",
1755
+ borderRadius: 8,
1756
+ paddingHorizontal: 12,
1757
+ paddingVertical: 10,
1758
+ color: "#111",
1759
+ marginBottom: 12,
1760
+ }}
729
1761
  />
1762
+ {!!error && <Text style={{ color: "red", marginBottom: 8 }}>{error}</Text>}
730
1763
  <Button title="Login" onPress={() => void handleLogin()} />
731
- </View>
1764
+ </ScreenLayout>
732
1765
  );
733
1766
  }
734
1767
  `,
735
- register,
1768
+ register: registerRedux,
736
1769
  home: `import React from "react";
737
- import { Button, Text, View } from "react-native";
1770
+ import { Button, Text } from "react-native";
1771
+ import ScreenLayout from "../../components/layout/ScreenLayout";
738
1772
  import { useDispatch } from "react-redux";
739
1773
  import { logout } from "../../store/authSlice";
740
1774
 
@@ -742,10 +1776,10 @@ export default function HomeScreen() {
742
1776
  const dispatch = useDispatch();
743
1777
 
744
1778
  return (
745
- <View style={{ padding: 20 }}>
1779
+ <ScreenLayout>
746
1780
  <Text>Welcome Home!</Text>
747
1781
  <Button title="Logout" onPress={() => dispatch(logout())} />
748
- </View>
1782
+ </ScreenLayout>
749
1783
  );
750
1784
  }
751
1785
  `,
@@ -755,8 +1789,10 @@ export default function HomeScreen() {
755
1789
  }
756
1790
  if (state === "Zustand") {
757
1791
  return {
1792
+ welcome: welcomeZustand,
758
1793
  login: `import React, { useState } from "react";
759
- import { Button, TextInput, View } from "react-native";
1794
+ import { Button, Text, TextInput } from "react-native";
1795
+ import ScreenLayout from "../../components/layout/ScreenLayout";
760
1796
  import { loginApi } from "../../api";
761
1797
  import { useAuthStore } from "../../store/authStore";
762
1798
 
@@ -764,39 +1800,74 @@ export default function LoginScreen() {
764
1800
  const login = useAuthStore((state) => state.login);
765
1801
  const [email, setEmail] = useState("");
766
1802
  const [password, setPassword] = useState("");
1803
+ const [error, setError] = useState("");
767
1804
 
768
1805
  const handleLogin = async () => {
769
- const token = await loginApi(email, password);
770
- await login(token);
1806
+ try {
1807
+ setError("");
1808
+ const token = await loginApi(email, password);
1809
+ await login(token);
1810
+ } catch (err) {
1811
+ setError(err instanceof Error ? err.message : "Login failed");
1812
+ }
771
1813
  };
772
1814
 
773
1815
  return (
774
- <View style={{ padding: 20 }}>
775
- <TextInput placeholder="Email" value={email} onChangeText={setEmail} />
1816
+ <ScreenLayout>
1817
+ <TextInput
1818
+ placeholder="Email"
1819
+ value={email}
1820
+ onChangeText={setEmail}
1821
+ autoCapitalize="none"
1822
+ autoCorrect={false}
1823
+ keyboardType="email-address"
1824
+ placeholderTextColor="#888"
1825
+ style={{
1826
+ borderWidth: 1,
1827
+ borderColor: "#ccc",
1828
+ borderRadius: 8,
1829
+ paddingHorizontal: 12,
1830
+ paddingVertical: 10,
1831
+ color: "#111",
1832
+ marginBottom: 12,
1833
+ }}
1834
+ />
776
1835
  <TextInput
777
1836
  placeholder="Password"
778
1837
  value={password}
779
1838
  onChangeText={setPassword}
780
1839
  secureTextEntry
1840
+ placeholderTextColor="#888"
1841
+ style={{
1842
+ borderWidth: 1,
1843
+ borderColor: "#ccc",
1844
+ borderRadius: 8,
1845
+ paddingHorizontal: 12,
1846
+ paddingVertical: 10,
1847
+ color: "#111",
1848
+ marginBottom: 12,
1849
+ }}
781
1850
  />
1851
+ {!!error && <Text style={{ color: "red", marginBottom: 8 }}>{error}</Text>}
782
1852
  <Button title="Login" onPress={() => void handleLogin()} />
783
- </View>
1853
+ </ScreenLayout>
784
1854
  );
785
1855
  }
786
1856
  `,
787
- register,
1857
+ register: registerZustand,
788
1858
  home: `import React from "react";
789
- import { Button, Text, View } from "react-native";
1859
+ import { Button, Text } from "react-native";
1860
+ import ScreenLayout from "../../components/layout/ScreenLayout";
790
1861
  import { useAuthStore } from "../../store/authStore";
791
1862
 
792
1863
  export default function HomeScreen() {
793
1864
  const logout = useAuthStore((state) => state.logout);
794
1865
 
795
1866
  return (
796
- <View style={{ padding: 20 }}>
1867
+ <ScreenLayout>
797
1868
  <Text>Welcome Home!</Text>
798
1869
  <Button title="Logout" onPress={() => void logout()} />
799
- </View>
1870
+ </ScreenLayout>
800
1871
  );
801
1872
  }
802
1873
  `,
@@ -805,8 +1876,10 @@ export default function HomeScreen() {
805
1876
  };
806
1877
  }
807
1878
  return {
1879
+ welcome: welcomeContext,
808
1880
  login: `import React, { useContext, useState } from "react";
809
- import { Button, TextInput, View } from "react-native";
1881
+ import { Button, Text, TextInput } from "react-native";
1882
+ import ScreenLayout from "../../components/layout/ScreenLayout";
810
1883
  import { loginApi } from "../../api";
811
1884
  import { AuthContext } from "../../context/AuthContext";
812
1885
 
@@ -814,39 +1887,74 @@ export default function LoginScreen() {
814
1887
  const { login } = useContext(AuthContext);
815
1888
  const [email, setEmail] = useState("");
816
1889
  const [password, setPassword] = useState("");
1890
+ const [error, setError] = useState("");
817
1891
 
818
1892
  const handleLogin = async () => {
819
- const token = await loginApi(email, password);
820
- await login(token);
1893
+ try {
1894
+ setError("");
1895
+ const token = await loginApi(email, password);
1896
+ await login(token);
1897
+ } catch (err) {
1898
+ setError(err instanceof Error ? err.message : "Login failed");
1899
+ }
821
1900
  };
822
1901
 
823
1902
  return (
824
- <View style={{ padding: 20 }}>
825
- <TextInput placeholder="Email" value={email} onChangeText={setEmail} />
1903
+ <ScreenLayout>
1904
+ <TextInput
1905
+ placeholder="Email"
1906
+ value={email}
1907
+ onChangeText={setEmail}
1908
+ autoCapitalize="none"
1909
+ autoCorrect={false}
1910
+ keyboardType="email-address"
1911
+ placeholderTextColor="#888"
1912
+ style={{
1913
+ borderWidth: 1,
1914
+ borderColor: "#ccc",
1915
+ borderRadius: 8,
1916
+ paddingHorizontal: 12,
1917
+ paddingVertical: 10,
1918
+ color: "#111",
1919
+ marginBottom: 12,
1920
+ }}
1921
+ />
826
1922
  <TextInput
827
1923
  placeholder="Password"
828
1924
  value={password}
829
1925
  onChangeText={setPassword}
830
1926
  secureTextEntry
1927
+ placeholderTextColor="#888"
1928
+ style={{
1929
+ borderWidth: 1,
1930
+ borderColor: "#ccc",
1931
+ borderRadius: 8,
1932
+ paddingHorizontal: 12,
1933
+ paddingVertical: 10,
1934
+ color: "#111",
1935
+ marginBottom: 12,
1936
+ }}
831
1937
  />
1938
+ {!!error && <Text style={{ color: "red", marginBottom: 8 }}>{error}</Text>}
832
1939
  <Button title="Login" onPress={() => void handleLogin()} />
833
- </View>
1940
+ </ScreenLayout>
834
1941
  );
835
1942
  }
836
1943
  `,
837
- register,
1944
+ register: registerContext,
838
1945
  home: `import React, { useContext } from "react";
839
- import { Button, Text, View } from "react-native";
1946
+ import { Button, Text } from "react-native";
1947
+ import ScreenLayout from "../../components/layout/ScreenLayout";
840
1948
  import { AuthContext } from "../../context/AuthContext";
841
1949
 
842
1950
  export default function HomeScreen() {
843
1951
  const { logout } = useContext(AuthContext);
844
1952
 
845
1953
  return (
846
- <View style={{ padding: 20 }}>
1954
+ <ScreenLayout>
847
1955
  <Text>Welcome Home!</Text>
848
1956
  <Button title="Logout" onPress={() => void logout()} />
849
- </View>
1957
+ </ScreenLayout>
850
1958
  );
851
1959
  }
852
1960
  `,
@@ -854,49 +1962,74 @@ export default function HomeScreen() {
854
1962
  settings,
855
1963
  };
856
1964
  }
857
- async function writeAuthAppShell(targetPath, state) {
1965
+ function getDataFetchingCliImports(dataFetching) {
1966
+ if (dataFetching === "React Query") {
1967
+ return `import { QueryClientProvider } from "@tanstack/react-query";\nimport { queryClient } from "./services/queryClient";\n`;
1968
+ }
1969
+ if (dataFetching === "SWR") {
1970
+ return `import { SWRConfig } from "swr";\n`;
1971
+ }
1972
+ return "";
1973
+ }
1974
+ function wrapCliNavWithDataFetching(inner, dataFetching) {
1975
+ if (dataFetching === "React Query") {
1976
+ return `<QueryClientProvider client={queryClient}>\n ${inner}\n </QueryClientProvider>`;
1977
+ }
1978
+ if (dataFetching === "SWR") {
1979
+ return `<SWRConfig value={{ revalidateOnFocus: true, revalidateOnReconnect: true }}>\n ${inner}\n </SWRConfig>`;
1980
+ }
1981
+ return inner;
1982
+ }
1983
+ async function writeAuthAppShell(targetPath, state, dataFetching = "None") {
858
1984
  const appPath = path_1.default.join(targetPath, "App.tsx");
1985
+ const dfImports = getDataFetchingCliImports(dataFetching);
1986
+ const contextInner = wrapCliNavWithDataFetching(`<NavigationContainer>\n <ProtectedStack />\n </NavigationContainer>`, dataFetching);
1987
+ const reduxInner = wrapCliNavWithDataFetching(`<NavigationContainer>\n <ProtectedStack />\n </NavigationContainer>`, dataFetching);
1988
+ const zustandInner = wrapCliNavWithDataFetching(`<NavigationContainer>\n <ProtectedStack />\n </NavigationContainer>`, dataFetching);
859
1989
  const byState = {
860
1990
  "Context API": `import React from "react";
861
1991
  import { NavigationContainer } from "@react-navigation/native";
862
- import { AuthProvider } from "./context/AuthContext";
1992
+ import { SafeAreaProvider } from "react-native-safe-area-context";
1993
+ ${dfImports}import { AuthProvider } from "./context/AuthContext";
863
1994
  import ProtectedStack from "./navigation/ProtectedStack";
864
1995
 
865
1996
  export default function App() {
866
1997
  return (
867
- <AuthProvider>
868
- <NavigationContainer>
869
- <ProtectedStack />
870
- </NavigationContainer>
871
- </AuthProvider>
1998
+ <SafeAreaProvider>
1999
+ <AuthProvider>
2000
+ ${contextInner}
2001
+ </AuthProvider>
2002
+ </SafeAreaProvider>
872
2003
  );
873
2004
  }
874
2005
  `,
875
2006
  "Redux Toolkit": `import React from "react";
876
2007
  import { NavigationContainer } from "@react-navigation/native";
877
2008
  import { Provider } from "react-redux";
878
- import ProtectedStack from "./navigation/ProtectedStack";
2009
+ import { SafeAreaProvider } from "react-native-safe-area-context";
2010
+ ${dfImports}import ProtectedStack from "./navigation/ProtectedStack";
879
2011
  import { store } from "./store/store";
880
2012
 
881
2013
  export default function App() {
882
2014
  return (
883
- <Provider store={store}>
884
- <NavigationContainer>
885
- <ProtectedStack />
886
- </NavigationContainer>
887
- </Provider>
2015
+ <SafeAreaProvider>
2016
+ <Provider store={store}>
2017
+ ${reduxInner}
2018
+ </Provider>
2019
+ </SafeAreaProvider>
888
2020
  );
889
2021
  }
890
2022
  `,
891
2023
  Zustand: `import React from "react";
892
2024
  import { NavigationContainer } from "@react-navigation/native";
893
- import ProtectedStack from "./navigation/ProtectedStack";
2025
+ import { SafeAreaProvider } from "react-native-safe-area-context";
2026
+ ${dfImports}import ProtectedStack from "./navigation/ProtectedStack";
894
2027
 
895
2028
  export default function App() {
896
2029
  return (
897
- <NavigationContainer>
898
- <ProtectedStack />
899
- </NavigationContainer>
2030
+ <SafeAreaProvider>
2031
+ ${zustandInner}
2032
+ </SafeAreaProvider>
900
2033
  );
901
2034
  }
902
2035
  `,
@@ -915,7 +2048,7 @@ async function configureTsconfigAlias(targetPath, absoluteImports) {
915
2048
  if (absoluteImports) {
916
2049
  tsconfig.compilerOptions.baseUrl = ".";
917
2050
  tsconfig.compilerOptions.paths = tsconfig.compilerOptions.paths || {};
918
- tsconfig.compilerOptions.paths["@/*"] = ["./*"];
2051
+ tsconfig.compilerOptions.paths["@/*"] = ["./src/*"];
919
2052
  }
920
2053
  else if (tsconfig.compilerOptions.paths) {
921
2054
  delete tsconfig.compilerOptions.paths["@/*"];
@@ -933,8 +2066,10 @@ async function writeCliBabelConfig(targetPath, options) {
933
2066
  if (options.useDotenv)
934
2067
  plugins.push("'module:react-native-dotenv'");
935
2068
  if (options.useAbsoluteImports) {
936
- plugins.push("['module-resolver', { root: ['./'], alias: { '@': './' } }]");
2069
+ plugins.push("['module-resolver', { root: ['./src'], alias: { '@': './src' } }]");
937
2070
  }
2071
+ // Must stay last when present.
2072
+ plugins.push("'react-native-worklets/plugin'");
938
2073
  const pluginsLine = plugins.length > 0 ? `,\n plugins: [${plugins.join(", ")}]` : "";
939
2074
  const content = `module.exports = {\n presets: ['module:@react-native/babel-preset']${pluginsLine},\n};\n`;
940
2075
  await fs_extra_1.default.writeFile(babelConfigPath, content, "utf8");
@@ -966,12 +2101,692 @@ async function ensureDependencies(targetPath, patch) {
966
2101
  await fs_extra_1.default.writeJson(packageJsonPath, packageJson, { spaces: 2 });
967
2102
  }
968
2103
  }
2104
+ function parseMajorMinor(version) {
2105
+ const match = version.match(/(\d+)\.(\d+)/);
2106
+ if (!match)
2107
+ return null;
2108
+ return { major: Number(match[1]), minor: Number(match[2]) };
2109
+ }
2110
+ async function validateGeneratedRuntimeCompatibility(targetPath) {
2111
+ const packageJsonPath = path_1.default.join(targetPath, "package.json");
2112
+ if (!(await fs_extra_1.default.pathExists(packageJsonPath)))
2113
+ return;
2114
+ const pkg = await fs_extra_1.default.readJson(packageJsonPath);
2115
+ const rnVersion = pkg.dependencies?.["react-native"];
2116
+ const reanimatedVersion = pkg.dependencies?.["react-native-reanimated"];
2117
+ if (!rnVersion || !reanimatedVersion)
2118
+ return;
2119
+ const rn = parseMajorMinor(String(rnVersion));
2120
+ const reanimated = parseMajorMinor(String(reanimatedVersion));
2121
+ if (!rn || !reanimated)
2122
+ return;
2123
+ // Reanimated 3 is not compatible with RN >= 0.82.
2124
+ if (rn.major === 0 && rn.minor >= 82 && reanimated.major < 4) {
2125
+ throw new Error(`❌ Invalid dependency combination detected: react-native@${rnVersion} with react-native-reanimated@${reanimatedVersion}. ` +
2126
+ `Use react-native-reanimated@4+ (and react-native-worklets) for React Native 0.${rn.minor}.x.`);
2127
+ }
2128
+ }
969
2129
  async function writeIfMissing(filePath, content) {
970
2130
  await fs_extra_1.default.ensureDir(path_1.default.dirname(filePath));
971
2131
  if (!(await fs_extra_1.default.pathExists(filePath))) {
972
2132
  await fs_extra_1.default.writeFile(filePath, content, "utf8");
973
2133
  }
974
2134
  }
2135
+ async function configureNavigationTypes(targetPath, options) {
2136
+ if (options.platform !== "React Native CLI" || !options.auth)
2137
+ return;
2138
+ await writeIfMissing(path_1.default.join(targetPath, "types/navigation.ts"), `import type { NativeStackScreenProps } from "@react-navigation/native-stack";
2139
+ import type { BottomTabScreenProps } from "@react-navigation/bottom-tabs";
2140
+ import type { CompositeScreenProps } from "@react-navigation/native";
2141
+
2142
+ export type AuthStackParamList = {
2143
+ Login: undefined;
2144
+ Register: undefined;
2145
+ };
2146
+
2147
+ export type MainTabParamList = {
2148
+ Home: undefined;
2149
+ Profile: undefined;
2150
+ Settings: undefined;
2151
+ };
2152
+
2153
+ export type AuthStackScreenProps<T extends keyof AuthStackParamList> =
2154
+ NativeStackScreenProps<AuthStackParamList, T>;
2155
+
2156
+ export type MainTabScreenProps<T extends keyof MainTabParamList> =
2157
+ CompositeScreenProps<
2158
+ BottomTabScreenProps<MainTabParamList, T>,
2159
+ NativeStackScreenProps<AuthStackParamList>
2160
+ >;
2161
+ `);
2162
+ }
2163
+ async function configureTheme(targetPath) {
2164
+ await writeIfMissing(path_1.default.join(targetPath, "theme/colors.ts"), `export const colors = {
2165
+ primary: "#111827",
2166
+ secondary: "#6B7280",
2167
+ background: "#F7F7F9",
2168
+ surface: "#FFFFFF",
2169
+ error: "#EF4444",
2170
+ success: "#10B981",
2171
+ warning: "#F59E0B",
2172
+ text: {
2173
+ primary: "#111827",
2174
+ secondary: "#4B5563",
2175
+ disabled: "#9CA3AF",
2176
+ inverse: "#FFFFFF",
2177
+ },
2178
+ border: "#E5E7EB",
2179
+ };
2180
+
2181
+ export type AppColors = typeof colors;
2182
+ `);
2183
+ await writeIfMissing(path_1.default.join(targetPath, "theme/spacing.ts"), `export const spacing = {
2184
+ xs: 4,
2185
+ sm: 8,
2186
+ md: 16,
2187
+ lg: 24,
2188
+ xl: 32,
2189
+ xxl: 48,
2190
+ } as const;
2191
+
2192
+ export type AppSpacing = typeof spacing;
2193
+ `);
2194
+ await writeIfMissing(path_1.default.join(targetPath, "theme/typography.ts"), `import { StyleSheet } from "react-native";
2195
+
2196
+ export const typography = StyleSheet.create({
2197
+ h1: { fontSize: 32, fontWeight: "700", lineHeight: 40 },
2198
+ h2: { fontSize: 24, fontWeight: "700", lineHeight: 32 },
2199
+ h3: { fontSize: 20, fontWeight: "600", lineHeight: 28 },
2200
+ body: { fontSize: 16, fontWeight: "400", lineHeight: 24 },
2201
+ bodySmall: { fontSize: 14, fontWeight: "400", lineHeight: 20 },
2202
+ caption: { fontSize: 12, fontWeight: "400", lineHeight: 16 },
2203
+ label: { fontSize: 14, fontWeight: "600", lineHeight: 20 },
2204
+ });
2205
+ `);
2206
+ await writeIfMissing(path_1.default.join(targetPath, "theme/index.ts"), `export { colors } from "./colors";
2207
+ export { spacing } from "./spacing";
2208
+ export { typography } from "./typography";
2209
+ export type { AppColors } from "./colors";
2210
+ export type { AppSpacing } from "./spacing";
2211
+ `);
2212
+ }
2213
+ async function configureErrorBoundary(targetPath) {
2214
+ await writeIfMissing(path_1.default.join(targetPath, "components/ErrorBoundary.tsx"), `import React, { Component, type ReactNode } from "react";
2215
+ import { Pressable, StyleSheet, Text, View } from "react-native";
2216
+
2217
+ type Props = {
2218
+ children: ReactNode;
2219
+ fallback?: ReactNode;
2220
+ onReset?: () => void;
2221
+ };
2222
+
2223
+ type State = {
2224
+ hasError: boolean;
2225
+ error: Error | null;
2226
+ };
2227
+
2228
+ export class ErrorBoundary extends Component<Props, State> {
2229
+ constructor(props: Props) {
2230
+ super(props);
2231
+ this.state = { hasError: false, error: null };
2232
+ }
2233
+
2234
+ static getDerivedStateFromError(error: Error): State {
2235
+ return { hasError: true, error };
2236
+ }
2237
+
2238
+ componentDidCatch(error: Error, info: { componentStack: string }) {
2239
+ console.error("ErrorBoundary caught:", error, info.componentStack);
2240
+ }
2241
+
2242
+ handleReset = () => {
2243
+ this.setState({ hasError: false, error: null });
2244
+ this.props.onReset?.();
2245
+ };
2246
+
2247
+ render() {
2248
+ if (this.state.hasError) {
2249
+ if (this.props.fallback) return this.props.fallback;
2250
+ return (
2251
+ <View style={styles.container}>
2252
+ <Text style={styles.title}>Something went wrong</Text>
2253
+ <Text style={styles.message}>{this.state.error?.message}</Text>
2254
+ <Pressable style={styles.button} onPress={this.handleReset}>
2255
+ <Text style={styles.buttonLabel}>Try again</Text>
2256
+ </Pressable>
2257
+ </View>
2258
+ );
2259
+ }
2260
+ return this.props.children;
2261
+ }
2262
+ }
2263
+
2264
+ const styles = StyleSheet.create({
2265
+ container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
2266
+ title: { fontSize: 20, fontWeight: "700", color: "#111827", marginBottom: 8 },
2267
+ message: { fontSize: 14, color: "#6B7280", textAlign: "center", marginBottom: 24 },
2268
+ button: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: "#111827", borderRadius: 8 },
2269
+ buttonLabel: { color: "#FFFFFF", fontSize: 14, fontWeight: "600" },
2270
+ });
2271
+ `);
2272
+ }
2273
+ async function configureTesting(targetPath) {
2274
+ // Pin react-test-renderer to the same React version already in the project
2275
+ // to avoid ERESOLVE peer dep conflicts (react-test-renderer requires exact react match)
2276
+ const pkgJsonPath = path_1.default.join(targetPath, "package.json");
2277
+ let reactVersion = "19.0.0";
2278
+ if (await fs_extra_1.default.pathExists(pkgJsonPath)) {
2279
+ const pkg = await fs_extra_1.default.readJson(pkgJsonPath);
2280
+ reactVersion =
2281
+ pkg.dependencies?.react ?? pkg.devDependencies?.react ?? "19.0.0";
2282
+ // Strip any semver range prefix so the pin is exact
2283
+ reactVersion = reactVersion.replace(/^[\^~]/, "");
2284
+ }
2285
+ await ensureDependencies(targetPath, {
2286
+ devDependencies: {
2287
+ "@testing-library/react-native": "^13.2.0",
2288
+ "@testing-library/jest-native": "^5.4.3",
2289
+ "react-test-renderer": reactVersion,
2290
+ },
2291
+ });
2292
+ // Wire @testing-library into jest.config.js so matchers like toBeVisible() work
2293
+ const jestConfigPath = path_1.default.join(targetPath, "jest.config.js");
2294
+ if (await fs_extra_1.default.pathExists(jestConfigPath)) {
2295
+ const content = await fs_extra_1.default.readFile(jestConfigPath, "utf8");
2296
+ if (!content.includes("setupFilesAfterEnv")) {
2297
+ await fs_extra_1.default.writeFile(jestConfigPath, `module.exports = {
2298
+ preset: 'react-native',
2299
+ setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
2300
+ transformIgnorePatterns: [
2301
+ 'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-reanimated)/)',
2302
+ ],
2303
+ };\n`, "utf8");
2304
+ }
2305
+ }
2306
+ }
2307
+ // ---------------------------------------------------------------------------
2308
+ // NativeWind (Tailwind CSS for React Native)
2309
+ // ---------------------------------------------------------------------------
2310
+ async function configureNativeWind(targetPath, platform) {
2311
+ // 1. Dependencies — matches official NativeWind v4 docs for each platform
2312
+ await ensureDependencies(targetPath, {
2313
+ dependencies: {
2314
+ nativewind: "^4.1.23",
2315
+ "react-native-reanimated": "^3.16.7",
2316
+ "react-native-safe-area-context": "^4.14.1",
2317
+ },
2318
+ devDependencies: {
2319
+ "tailwindcss": "^3.4.17",
2320
+ "prettier-plugin-tailwindcss": "^0.5.11",
2321
+ ...(platform === "Expo" ? { "babel-preset-expo": "^12.0.10" } : {}),
2322
+ },
2323
+ });
2324
+ // 2. tailwind.config.js
2325
+ const contentGlobs = platform === "Expo"
2326
+ ? `["./app/**/*.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"]`
2327
+ : `["./App.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}"]`;
2328
+ await writeIfMissing(path_1.default.join(targetPath, "tailwind.config.js"), `/** @type {import('tailwindcss').Config} */
2329
+ module.exports = {
2330
+ content: ${contentGlobs},
2331
+ presets: [require("nativewind/preset")],
2332
+ theme: {
2333
+ extend: {},
2334
+ },
2335
+ plugins: [],
2336
+ };
2337
+ `);
2338
+ // 3. global.css
2339
+ await writeIfMissing(path_1.default.join(targetPath, "global.css"), `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n`);
2340
+ const metroPath = path_1.default.join(targetPath, "metro.config.js");
2341
+ if (platform === "Expo") {
2342
+ // --- Expo setup (official docs) ---
2343
+ // metro.config.js
2344
+ await fs_extra_1.default.writeFile(metroPath, `const { getDefaultConfig } = require("expo/metro-config");
2345
+ const { withNativeWind } = require("nativewind/metro");
2346
+
2347
+ const config = getDefaultConfig(__dirname);
2348
+
2349
+ module.exports = withNativeWind(config, { input: "./global.css" });
2350
+ `, "utf8");
2351
+ // babel.config.js — jsxImportSource + nativewind/babel preset
2352
+ await fs_extra_1.default.writeFile(path_1.default.join(targetPath, "babel.config.js"), `module.exports = function (api) {
2353
+ api.cache(true);
2354
+ return {
2355
+ presets: [
2356
+ ["babel-preset-expo", { jsxImportSource: "nativewind" }],
2357
+ "nativewind/babel",
2358
+ ],
2359
+ };
2360
+ };
2361
+ `, "utf8");
2362
+ // app.json — enable Metro web bundler
2363
+ const appJsonPath = path_1.default.join(targetPath, "app.json");
2364
+ if (await fs_extra_1.default.pathExists(appJsonPath)) {
2365
+ const appJson = await fs_extra_1.default.readJson(appJsonPath);
2366
+ appJson.expo = appJson.expo || {};
2367
+ appJson.expo.web = { ...(appJson.expo.web || {}), bundler: "metro" };
2368
+ await fs_extra_1.default.writeJson(appJsonPath, appJson, { spaces: 2 });
2369
+ }
2370
+ // Import global.css in app/_layout.tsx
2371
+ const layoutPath = path_1.default.join(targetPath, "app", "_layout.tsx");
2372
+ if (await fs_extra_1.default.pathExists(layoutPath)) {
2373
+ const layoutContent = await fs_extra_1.default.readFile(layoutPath, "utf8");
2374
+ if (!layoutContent.includes("global.css")) {
2375
+ await fs_extra_1.default.writeFile(layoutPath, `import "../global.css";\n${layoutContent}`, "utf8");
2376
+ }
2377
+ }
2378
+ }
2379
+ else {
2380
+ // --- React Native CLI setup (official docs) ---
2381
+ // metro.config.js
2382
+ await fs_extra_1.default.writeFile(metroPath, `const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
2383
+ const { withNativeWind } = require("nativewind/metro");
2384
+
2385
+ const config = mergeConfig(getDefaultConfig(__dirname), {
2386
+ /* your config */
2387
+ });
2388
+
2389
+ module.exports = withNativeWind(config, { input: "./global.css" });
2390
+ `, "utf8");
2391
+ // babel.config.js — append nativewind/babel as a preset
2392
+ const babelPath = path_1.default.join(targetPath, "babel.config.js");
2393
+ if (await fs_extra_1.default.pathExists(babelPath)) {
2394
+ let babelContent = await fs_extra_1.default.readFile(babelPath, "utf8");
2395
+ if (!babelContent.includes("nativewind/babel")) {
2396
+ // Add as a preset alongside the existing @react-native/babel-preset
2397
+ babelContent = babelContent.replace(/presets:\s*\[([^\]]*)\]/, (match, inner) => match.replace(inner, `${inner.trimEnd()}, 'nativewind/babel'`));
2398
+ if (!babelContent.includes("nativewind/babel")) {
2399
+ babelContent = babelContent.replace(/module\.exports\s*=\s*\{/, `module.exports = {\n presets: ['nativewind/babel'],`);
2400
+ }
2401
+ await fs_extra_1.default.writeFile(babelPath, babelContent, "utf8");
2402
+ }
2403
+ }
2404
+ // Import global.css in index.js
2405
+ const indexPath = path_1.default.join(targetPath, "index.js");
2406
+ if (await fs_extra_1.default.pathExists(indexPath)) {
2407
+ const indexContent = await fs_extra_1.default.readFile(indexPath, "utf8");
2408
+ if (!indexContent.includes("global.css")) {
2409
+ await fs_extra_1.default.writeFile(indexPath, `import "./global.css";\n${indexContent}`, "utf8");
2410
+ }
2411
+ }
2412
+ }
2413
+ // 4. TypeScript: create nativewind-env.d.ts (official recommended approach)
2414
+ await writeIfMissing(path_1.default.join(targetPath, "nativewind-env.d.ts"), `/// <reference types="nativewind/types" />\n`);
2415
+ // 5. Overwrite home.tsx with a NativeWind version so users see className in use immediately
2416
+ if (platform === "Expo") {
2417
+ const homePath = path_1.default.join(targetPath, "app", "home.tsx");
2418
+ const projectName = path_1.default.basename(targetPath);
2419
+ await fs_extra_1.default.writeFile(homePath, `import React from "react";
2420
+ import { Text, View } from "react-native";
2421
+ import { SafeAreaView } from "react-native-safe-area-context";
2422
+
2423
+ export default function HomeScreen() {
2424
+ return (
2425
+ <SafeAreaView className="flex-1 bg-gray-50">
2426
+ <View className="flex-1 items-center justify-center px-6">
2427
+ <Text className="text-3xl font-bold text-gray-900 mb-2">${projectName}</Text>
2428
+ <Text className="text-base text-gray-500">Your app starts here.</Text>
2429
+ </View>
2430
+ </SafeAreaView>
2431
+ );
2432
+ }
2433
+ `, "utf8");
2434
+ }
2435
+ console.log("🎨 NativeWind configured — use className props on your React Native components.");
2436
+ }
2437
+ async function stampVersion(targetPath, options) {
2438
+ const packageJsonPath = path_1.default.join(targetPath, "package.json");
2439
+ if (!(await fs_extra_1.default.pathExists(packageJsonPath)))
2440
+ return;
2441
+ // Resolve the CLI's own package.json regardless of cwd
2442
+ const cliPkgCandidates = [
2443
+ path_1.default.resolve(__dirname, "../../../package.json"), // dist/src/generators -> root
2444
+ path_1.default.resolve(__dirname, "../../package.json"), // ts-node: src/generators -> root
2445
+ path_1.default.resolve(process.cwd(), "package.json"), // fallback
2446
+ ];
2447
+ let cliVersion = "unknown";
2448
+ for (const candidate of cliPkgCandidates) {
2449
+ if (await fs_extra_1.default.pathExists(candidate)) {
2450
+ const cliPkg = await fs_extra_1.default.readJson(candidate);
2451
+ if (cliPkg.name?.includes("rnstarterkit")) {
2452
+ cliVersion = cliPkg.version ?? "unknown";
2453
+ break;
2454
+ }
2455
+ }
2456
+ }
2457
+ const projectPkg = await fs_extra_1.default.readJson(packageJsonPath);
2458
+ // Save full config so `generate` subcommand can read project context
2459
+ projectPkg.rnstarterkitConfig = {
2460
+ version: cliVersion,
2461
+ platform: options.platform,
2462
+ state: options.state,
2463
+ auth: options.auth,
2464
+ dataFetching: options.dataFetching,
2465
+ storage: options.storage,
2466
+ apiClient: options.apiClient,
2467
+ sentry: options.sentry,
2468
+ i18n: options.i18n,
2469
+ maestro: options.maestro,
2470
+ nativewind: options.nativewind,
2471
+ };
2472
+ await fs_extra_1.default.writeJson(packageJsonPath, projectPkg, { spaces: 2 });
2473
+ }
2474
+ async function configureVSCode(targetPath) {
2475
+ await writeIfMissing(path_1.default.join(targetPath, ".vscode", "extensions.json"), JSON.stringify({
2476
+ recommendations: [
2477
+ "dbaeumer.vscode-eslint",
2478
+ "esbenp.prettier-vscode",
2479
+ "msjsdiag.vscode-react-native",
2480
+ "orta.vscode-jest",
2481
+ "pflannery.vscode-versionlens",
2482
+ ],
2483
+ }, null, 2) + "\n");
2484
+ await writeIfMissing(path_1.default.join(targetPath, ".vscode", "settings.json"), JSON.stringify({
2485
+ "editor.formatOnSave": true,
2486
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
2487
+ "editor.codeActionsOnSave": {
2488
+ "source.fixAll.eslint": "explicit",
2489
+ },
2490
+ "typescript.preferences.importModuleSpecifier": "relative",
2491
+ "typescript.tsdk": "node_modules/typescript/lib",
2492
+ "emmet.includeLanguages": { typescript: "javascript" },
2493
+ "files.exclude": {
2494
+ "**/.git": true,
2495
+ "**/node_modules": true,
2496
+ "ios/Pods": true,
2497
+ "android/.gradle": true,
2498
+ },
2499
+ }, null, 2) + "\n");
2500
+ }
2501
+ async function configureSentry(targetPath, platform) {
2502
+ await ensureDependencies(targetPath, {
2503
+ dependencies: { "@sentry/react-native": "^6.4.0" },
2504
+ });
2505
+ // Write platform-aware sentry.ts — DSN is read from env, never hardcoded
2506
+ const isExpo = platform === "Expo";
2507
+ const dsnVar = isExpo ? "process.env.EXPO_PUBLIC_SENTRY_DSN" : "Config.SENTRY_DSN";
2508
+ const dsnImport = isExpo
2509
+ ? ""
2510
+ : `import Config from 'react-native-config';\n`;
2511
+ const sentryContent = `${dsnImport}import * as Sentry from '@sentry/react-native';
2512
+
2513
+ export function initSentry() {
2514
+ const dsn = ${dsnVar};
2515
+ if (!dsn) {
2516
+ console.warn('[Sentry] DSN not set. Add ${isExpo ? "EXPO_PUBLIC_SENTRY_DSN" : "SENTRY_DSN"} to your .env file.');
2517
+ return;
2518
+ }
2519
+ Sentry.init({
2520
+ dsn,
2521
+ tracesSampleRate: 1.0,
2522
+ debug: __DEV__,
2523
+ attachStacktrace: true,
2524
+ environment: __DEV__ ? 'development' : 'production',
2525
+ });
2526
+ }
2527
+
2528
+ /** Manually capture an exception (e.g. in a catch block) */
2529
+ export function captureException(error: unknown) {
2530
+ Sentry.captureException(error);
2531
+ }
2532
+
2533
+ /** Add a breadcrumb for tracing user flows */
2534
+ export function addBreadcrumb(message: string, category = 'app') {
2535
+ Sentry.addBreadcrumb({ message, category });
2536
+ }
2537
+ `;
2538
+ await writeIfMissing(path_1.default.join(targetPath, "src", "utils", "sentry.ts"), sentryContent);
2539
+ // Add SENTRY_DSN to .env.example so devs know where to put it
2540
+ const envExamplePath = path_1.default.join(targetPath, ".env.example");
2541
+ if (await fs_extra_1.default.pathExists(envExamplePath)) {
2542
+ const envContent = await fs_extra_1.default.readFile(envExamplePath, "utf8");
2543
+ const sentryKey = isExpo ? "EXPO_PUBLIC_SENTRY_DSN" : "SENTRY_DSN";
2544
+ if (!envContent.includes(sentryKey)) {
2545
+ await fs_extra_1.default.appendFile(envExamplePath, `\n# Sentry — get your DSN from https://sentry.io → Project Settings → Client Keys\n${sentryKey}=\n`);
2546
+ }
2547
+ }
2548
+ // Inject initSentry into app entry point
2549
+ const entryFile = isExpo
2550
+ ? path_1.default.join(targetPath, "app", "_layout.tsx")
2551
+ : path_1.default.join(targetPath, "App.tsx");
2552
+ if (await fs_extra_1.default.pathExists(entryFile)) {
2553
+ let content = await fs_extra_1.default.readFile(entryFile, "utf8");
2554
+ if (!content.includes("initSentry")) {
2555
+ const sentryImportPath = isExpo ? '../src/utils/sentry' : './src/utils/sentry';
2556
+ content = content.replace(/^(import .+\n)+/m, (match) => `${match}import { initSentry } from '${sentryImportPath}';\n`);
2557
+ content = content.replace(/(export default|function )/, `initSentry();\n\n$1`);
2558
+ await fs_extra_1.default.writeFile(entryFile, content, "utf8");
2559
+ }
2560
+ }
2561
+ console.log(`➕ Sentry configured. Add your DSN to .env:\n ${isExpo ? "EXPO_PUBLIC_SENTRY_DSN" : "SENTRY_DSN"}=https://...@sentry.io/...`);
2562
+ }
2563
+ async function configureI18n(targetPath, platform) {
2564
+ const isExpo = platform === "Expo";
2565
+ // Copy locale files (JSON only — platform-agnostic)
2566
+ await copyOptionalModule("i18n", targetPath);
2567
+ // Write platform-specific i18n.ts — Expo uses expo-localization (no native
2568
+ // linking), RN CLI uses react-native-localize (requires native module)
2569
+ const i18nContent = isExpo
2570
+ ? `import i18n from 'i18next';
2571
+ import { initReactI18next } from 'react-i18next';
2572
+ import * as Localization from 'expo-localization';
2573
+
2574
+ import en from './locales/en.json';
2575
+ import es from './locales/es.json';
2576
+
2577
+ const resources = {
2578
+ en: { translation: en },
2579
+ es: { translation: es },
2580
+ };
2581
+
2582
+ const fallbackLocale = 'en';
2583
+ const bestMatch = Localization.getLocales()[0]?.languageCode ?? fallbackLocale;
2584
+
2585
+ void i18n
2586
+ .use(initReactI18next)
2587
+ .init({
2588
+ resources,
2589
+ lng: bestMatch,
2590
+ fallbackLng: fallbackLocale,
2591
+ interpolation: { escapeValue: false },
2592
+ compatibilityJSON: 'v4',
2593
+ });
2594
+
2595
+ export default i18n;
2596
+ `
2597
+ : `import i18n from 'i18next';
2598
+ import { initReactI18next } from 'react-i18next';
2599
+ import * as RNLocalize from 'react-native-localize';
2600
+
2601
+ import en from './locales/en.json';
2602
+ import es from './locales/es.json';
2603
+
2604
+ const resources = {
2605
+ en: { translation: en },
2606
+ es: { translation: es },
2607
+ };
2608
+
2609
+ const fallbackLocale = 'en';
2610
+ const bestMatch = RNLocalize.getLocales()[0]?.languageCode ?? fallbackLocale;
2611
+
2612
+ void i18n
2613
+ .use(initReactI18next)
2614
+ .init({
2615
+ resources,
2616
+ lng: bestMatch,
2617
+ fallbackLng: fallbackLocale,
2618
+ interpolation: { escapeValue: false },
2619
+ compatibilityJSON: 'v4',
2620
+ });
2621
+
2622
+ export default i18n;
2623
+ `;
2624
+ // Overwrite the template's generic i18n.ts with the platform-correct version
2625
+ await fs_extra_1.default.writeFile(path_1.default.join(targetPath, "src", "i18n", "i18n.ts"), i18nContent, "utf8");
2626
+ await ensureDependencies(targetPath, {
2627
+ dependencies: isExpo
2628
+ ? {
2629
+ i18next: "^24.2.0",
2630
+ "react-i18next": "^15.4.0",
2631
+ "expo-localization": "^16.0.0",
2632
+ }
2633
+ : {
2634
+ i18next: "^24.2.0",
2635
+ "react-i18next": "^15.4.0",
2636
+ "react-native-localize": "^3.4.0",
2637
+ },
2638
+ });
2639
+ // Inject i18n initialisation import into app entry
2640
+ const entryFile = isExpo
2641
+ ? path_1.default.join(targetPath, "app", "_layout.tsx")
2642
+ : path_1.default.join(targetPath, "App.tsx");
2643
+ if (await fs_extra_1.default.pathExists(entryFile)) {
2644
+ let content = await fs_extra_1.default.readFile(entryFile, "utf8");
2645
+ if (!content.includes("i18n/i18n")) {
2646
+ // Import path differs by platform
2647
+ const importPath = isExpo ? "../src/i18n/i18n" : "./src/i18n/i18n";
2648
+ content = `import '${importPath}';\n${content}`;
2649
+ await fs_extra_1.default.writeFile(entryFile, content, "utf8");
2650
+ }
2651
+ }
2652
+ }
2653
+ async function configureMaestro(targetPath, options) {
2654
+ await copyOptionalModule(options.auth ? "maestro-auth" : "maestro", targetPath);
2655
+ // Extend CI workflow with a maestro job if CI was also selected
2656
+ if (options.ci) {
2657
+ const ciPath = path_1.default.join(targetPath, ".github", "workflows", "ci.yml");
2658
+ if (await fs_extra_1.default.pathExists(ciPath)) {
2659
+ const content = await fs_extra_1.default.readFile(ciPath, "utf8");
2660
+ if (!content.includes("maestro")) {
2661
+ await fs_extra_1.default.appendFile(ciPath, `
2662
+ e2e:
2663
+ name: E2E (Maestro)
2664
+ runs-on: macos-latest
2665
+ steps:
2666
+ - uses: actions/checkout@v4
2667
+ - uses: actions/setup-node@v4
2668
+ with:
2669
+ node-version: 20
2670
+ cache: npm
2671
+ - run: npm install
2672
+ - name: Install Maestro
2673
+ run: curl -Ls "https://get.maestro.mobile.dev" | bash
2674
+ - run: maestro test .maestro/flows/
2675
+ `);
2676
+ }
2677
+ }
2678
+ }
2679
+ }
2680
+ async function configureEnv(targetPath, platform) {
2681
+ // Always create .env.example so devs know where to put secrets
2682
+ await writeIfMissing(path_1.default.join(targetPath, ".env.example"), `# Environment variables — copy to .env and fill in values
2683
+ # Never commit .env to source control
2684
+
2685
+ API_URL=https://api.example.com
2686
+ APP_ENV=development
2687
+ `);
2688
+ // Ensure .env is gitignored
2689
+ const gitignorePath = path_1.default.join(targetPath, ".gitignore");
2690
+ if (await fs_extra_1.default.pathExists(gitignorePath)) {
2691
+ const content = await fs_extra_1.default.readFile(gitignorePath, "utf8");
2692
+ if (!content.includes(".env")) {
2693
+ await fs_extra_1.default.appendFile(gitignorePath, "\n# Environment variables\n.env\n");
2694
+ }
2695
+ }
2696
+ if (platform === "React Native CLI") {
2697
+ // react-native-config: reads .env and exposes via Config.*
2698
+ await ensureDependencies(targetPath, {
2699
+ dependencies: { "react-native-config": "^1.5.1" },
2700
+ });
2701
+ await writeIfMissing(path_1.default.join(targetPath, "src", "config", "env.ts"), `import Config from 'react-native-config';
2702
+
2703
+ export const ENV = {
2704
+ API_URL: Config.API_URL ?? 'https://api.example.com',
2705
+ APP_ENV: Config.APP_ENV ?? 'development',
2706
+ } as const;
2707
+ `);
2708
+ }
2709
+ else {
2710
+ // Expo: use expo-constants / process.env (works with babel inline transform)
2711
+ await writeIfMissing(path_1.default.join(targetPath, "src", "config", "env.ts"), `// Expo exposes env vars via process.env (set in app.config.js extra field)
2712
+ // or via babel-plugin-transform-inline-environment-variables
2713
+
2714
+ export const ENV = {
2715
+ API_URL: process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com',
2716
+ APP_ENV: process.env.EXPO_PUBLIC_APP_ENV ?? 'development',
2717
+ } as const;
2718
+ `);
2719
+ }
2720
+ }
2721
+ async function configureHusky(targetPath) {
2722
+ await ensureDependencies(targetPath, {
2723
+ devDependencies: {
2724
+ husky: "^9.1.0",
2725
+ "lint-staged": "^15.2.0",
2726
+ },
2727
+ });
2728
+ const packageJsonPath = path_1.default.join(targetPath, "package.json");
2729
+ if (await fs_extra_1.default.pathExists(packageJsonPath)) {
2730
+ const packageJson = await fs_extra_1.default.readJson(packageJsonPath);
2731
+ packageJson.scripts = packageJson.scripts || {};
2732
+ if (!packageJson.scripts.prepare) {
2733
+ packageJson.scripts.prepare = "husky";
2734
+ }
2735
+ if (!packageJson["lint-staged"]) {
2736
+ packageJson["lint-staged"] = {
2737
+ "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
2738
+ };
2739
+ }
2740
+ await fs_extra_1.default.writeJson(packageJsonPath, packageJson, { spaces: 2 });
2741
+ }
2742
+ const huskyDir = path_1.default.join(targetPath, ".husky");
2743
+ await fs_extra_1.default.ensureDir(huskyDir);
2744
+ await writeIfMissing(path_1.default.join(huskyDir, "pre-commit"), `npx lint-staged\n`);
2745
+ // Make pre-commit executable
2746
+ try {
2747
+ await fs_extra_1.default.chmod(path_1.default.join(huskyDir, "pre-commit"), 0o755);
2748
+ }
2749
+ catch {
2750
+ // chmod may not work on all platforms, safe to ignore
2751
+ }
2752
+ }
2753
+ async function configureCi(targetPath) {
2754
+ const workflowsDir = path_1.default.join(targetPath, ".github", "workflows");
2755
+ await fs_extra_1.default.ensureDir(workflowsDir);
2756
+ await writeIfMissing(path_1.default.join(workflowsDir, "ci.yml"), `name: CI
2757
+
2758
+ on:
2759
+ push:
2760
+ branches: [main, develop]
2761
+ pull_request:
2762
+ branches: [main, develop]
2763
+
2764
+ jobs:
2765
+ lint:
2766
+ name: Lint
2767
+ runs-on: ubuntu-latest
2768
+ steps:
2769
+ - uses: actions/checkout@v4
2770
+ - uses: actions/setup-node@v4
2771
+ with:
2772
+ node-version: 20
2773
+ cache: npm
2774
+ - run: npm install
2775
+ - run: npm run lint
2776
+
2777
+ test:
2778
+ name: Test
2779
+ runs-on: ubuntu-latest
2780
+ steps:
2781
+ - uses: actions/checkout@v4
2782
+ - uses: actions/setup-node@v4
2783
+ with:
2784
+ node-version: 20
2785
+ cache: npm
2786
+ - run: npm install
2787
+ - run: npm test -- --passWithNoTests
2788
+ `);
2789
+ }
975
2790
  async function resolveTemplateRoot() {
976
2791
  const candidates = [
977
2792
  path_1.default.join(__dirname, "../templates"), // ts-node: src/generators -> src/templates
@@ -1029,6 +2844,8 @@ function isTextFile(filePath) {
1029
2844
  ".lock",
1030
2845
  ".xcprivacy",
1031
2846
  ".storyboard",
2847
+ ".xcworkspacedata",
2848
+ ".xcscheme",
1032
2849
  ]);
1033
2850
  const base = path_1.default.basename(filePath).toLowerCase();
1034
2851
  return textExtensions.has(extension) || base === "podfile" || base === "gemfile";