@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.
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/dist/bin/create-rnstarterkit.js +205 -7
- package/dist/src/generators/appGenerator.js +1944 -127
- package/dist/src/generators/codeGenerator.js +289 -0
- package/dist/templates/cli-base/App.tsx +199 -21
- package/dist/templates/cli-base/assets/images/icon.png +0 -0
- package/dist/templates/cli-base/assets/images/partial-react-logo.png +0 -0
- package/dist/templates/cli-base/assets/images/react-logo.png +0 -0
- package/dist/templates/cli-base/babel.config.js +1 -0
- package/dist/templates/cli-base/ios/BaseApp.xcodeproj/project.pbxproj +6 -0
- package/dist/templates/cli-base/ios/Podfile +5 -0
- package/dist/templates/cli-base/jest.config.js +4 -0
- package/dist/templates/cli-base/package.json +7 -4
- package/dist/templates/cli-base/tsconfig.json +1 -0
- package/dist/templates/expo-base/app/_layout.tsx +7 -3
- package/dist/templates/expo-base/app/home.tsx +37 -0
- package/dist/templates/expo-base/app/index.tsx +166 -5
- package/dist/templates/expo-base/app.json +1 -2
- package/dist/templates/expo-base/package.json +5 -2
- package/dist/templates/optional/auth-context/components/layout/ScreenLayout.tsx +46 -0
- package/dist/templates/optional/auth-context/navigation/ProtectedStack.tsx +33 -5
- package/dist/templates/optional/auth-context/screens/HomeScreen.tsx +4 -3
- package/dist/templates/optional/auth-context/screens/LoginScreen.tsx +41 -6
- package/dist/templates/optional/auth-context/screens/ProfileScreen.tsx +4 -3
- package/dist/templates/optional/auth-context/screens/RegisterScreen.tsx +41 -6
- package/dist/templates/optional/auth-context/screens/SettingsScreen.tsx +4 -3
- package/dist/templates/optional/auth-context/screens/WelcomeScreen.tsx +174 -0
- package/dist/templates/optional/auth-redux/components/layout/ScreenLayout.tsx +46 -0
- package/dist/templates/optional/auth-redux/navigation/ProtectedStack.tsx +39 -2
- package/dist/templates/optional/auth-redux/screens/HomeScreen.tsx +4 -3
- package/dist/templates/optional/auth-redux/screens/LoginScreen.tsx +42 -7
- package/dist/templates/optional/auth-redux/screens/ProfileScreen.tsx +7 -10
- package/dist/templates/optional/auth-redux/screens/RegisterScreen.tsx +61 -11
- package/dist/templates/optional/auth-redux/screens/SettingsScreen.tsx +6 -9
- package/dist/templates/optional/auth-redux/screens/WelcomeScreen.tsx +174 -0
- package/dist/templates/optional/auth-zustand/components/layout/ScreenLayout.tsx +46 -0
- package/dist/templates/optional/auth-zustand/navigation/ProtectedStack.tsx +34 -6
- package/dist/templates/optional/auth-zustand/screens/HomeScreen.tsx +4 -3
- package/dist/templates/optional/auth-zustand/screens/LoginScreen.tsx +41 -6
- package/dist/templates/optional/auth-zustand/screens/ProfileScreen.tsx +4 -3
- package/dist/templates/optional/auth-zustand/screens/RegisterScreen.tsx +41 -6
- package/dist/templates/optional/auth-zustand/screens/SettingsScreen.tsx +4 -3
- package/dist/templates/optional/auth-zustand/screens/WelcomeScreen.tsx +174 -0
- package/dist/templates/optional/ci/.github/workflows/ci.yml +32 -0
- package/dist/templates/optional/error-boundary/components/ErrorBoundary.tsx +83 -0
- package/dist/templates/optional/formik/components/formik/FormikInput.tsx +45 -0
- package/dist/templates/optional/formik/components/formik/LoginForm.tsx +60 -0
- package/dist/templates/optional/formik/schemas/auth.schema.ts +17 -0
- package/dist/templates/optional/i18n/src/i18n/hooks/useAppTranslation.ts +28 -0
- package/dist/templates/optional/i18n/src/i18n/i18n.ts +30 -0
- package/dist/templates/optional/i18n/src/i18n/locales/en.json +32 -0
- package/dist/templates/optional/i18n/src/i18n/locales/es.json +32 -0
- package/dist/templates/optional/maestro/.maestro/flows/01_welcome.yaml +5 -0
- package/dist/templates/optional/maestro-auth/.maestro/flows/01_welcome.yaml +5 -0
- package/dist/templates/optional/maestro-auth/.maestro/flows/02_login.yaml +13 -0
- package/dist/templates/optional/maestro-auth/.maestro/flows/03_logout.yaml +16 -0
- package/dist/templates/optional/mmkv/utils/storage.ts +17 -0
- package/dist/templates/optional/react-hook-form/components/rhf/LoginForm.tsx +63 -0
- package/dist/templates/optional/react-hook-form/components/rhf/RHFInput.tsx +50 -0
- package/dist/templates/optional/react-hook-form/schemas/auth.schema.ts +29 -0
- package/dist/templates/optional/react-query/hooks/useAppMutation.ts +16 -0
- package/dist/templates/optional/react-query/hooks/useAppQuery.ts +12 -0
- package/dist/templates/optional/react-query/services/queryClient.ts +14 -0
- package/dist/templates/optional/redux/store/hooks.ts +6 -0
- package/dist/templates/optional/redux/store/store.ts +11 -0
- package/dist/templates/optional/sentry/src/utils/sentry.ts +24 -0
- package/dist/templates/optional/swr/hooks/useSWRFetch.ts +14 -0
- package/dist/templates/optional/swr/providers/SWRProvider.tsx +21 -0
- package/dist/templates/optional/tsconfig.json +17 -0
- package/dist/templates/optional/zustand/store/appStore.ts +13 -0
- package/package.json +40 -5
- package/dist/templates/expo-base/components/ui/collapsible.tsx +0 -45
- package/dist/templates/expo-base/components/ui/icon-symbol.ios.tsx +0 -32
- 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
|
-
|
|
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 (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
112
|
-
console.log("⚠️
|
|
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
|
-
"
|
|
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
|
|
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 {
|
|
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
|
-
<
|
|
428
|
-
<
|
|
429
|
-
|
|
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
|
|
697
|
+
return (
|
|
698
|
+
<SafeAreaProvider>
|
|
699
|
+
${slot}
|
|
700
|
+
</SafeAreaProvider>
|
|
701
|
+
);
|
|
487
702
|
}
|
|
488
703
|
`,
|
|
489
|
-
indexRoute: `import React from "react";
|
|
490
|
-
import
|
|
491
|
-
import { ActivityIndicator
|
|
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
|
-
|
|
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
|
-
<
|
|
737
|
+
<ScreenLayout centered padded={false}>
|
|
507
738
|
<ActivityIndicator />
|
|
508
|
-
</
|
|
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
|
|
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
|
-
<
|
|
764
|
+
<ScreenLayout centered padded={false}>
|
|
532
765
|
<ActivityIndicator />
|
|
533
|
-
</
|
|
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
|
|
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
|
-
<
|
|
797
|
+
<ScreenLayout centered padded={false}>
|
|
564
798
|
<ActivityIndicator />
|
|
565
|
-
</
|
|
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 {
|
|
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
|
-
<
|
|
590
|
-
<
|
|
591
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
860
|
+
<ScreenLayout centered padded={false}>
|
|
606
861
|
<ActivityIndicator />
|
|
607
|
-
</
|
|
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
|
|
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
|
-
<
|
|
881
|
+
<ScreenLayout centered padded={false}>
|
|
625
882
|
<ActivityIndicator />
|
|
626
|
-
</
|
|
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
|
|
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
|
-
<
|
|
908
|
+
<ScreenLayout centered padded={false}>
|
|
651
909
|
<ActivityIndicator />
|
|
652
|
-
</
|
|
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
|
|
671
|
-
import
|
|
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
|
-
<
|
|
676
|
-
<
|
|
677
|
-
|
|
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
|
|
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
|
-
<
|
|
1682
|
+
<ScreenLayout>
|
|
687
1683
|
<Text>ProfileScreen</Text>
|
|
688
|
-
</
|
|
1684
|
+
</ScreenLayout>
|
|
689
1685
|
);
|
|
690
1686
|
}
|
|
691
1687
|
`;
|
|
692
1688
|
const settings = `import React from "react";
|
|
693
|
-
import { Text
|
|
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
|
-
<
|
|
1694
|
+
<ScreenLayout>
|
|
698
1695
|
<Text>SettingsScreen</Text>
|
|
699
|
-
</
|
|
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,
|
|
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
|
-
|
|
718
|
-
|
|
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
|
-
<
|
|
723
|
-
<TextInput
|
|
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
|
-
</
|
|
1764
|
+
</ScreenLayout>
|
|
732
1765
|
);
|
|
733
1766
|
}
|
|
734
1767
|
`,
|
|
735
|
-
register,
|
|
1768
|
+
register: registerRedux,
|
|
736
1769
|
home: `import React from "react";
|
|
737
|
-
import { Button, Text
|
|
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
|
-
<
|
|
1779
|
+
<ScreenLayout>
|
|
746
1780
|
<Text>Welcome Home!</Text>
|
|
747
1781
|
<Button title="Logout" onPress={() => dispatch(logout())} />
|
|
748
|
-
</
|
|
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,
|
|
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
|
-
|
|
770
|
-
|
|
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
|
-
<
|
|
775
|
-
<TextInput
|
|
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
|
-
</
|
|
1853
|
+
</ScreenLayout>
|
|
784
1854
|
);
|
|
785
1855
|
}
|
|
786
1856
|
`,
|
|
787
|
-
register,
|
|
1857
|
+
register: registerZustand,
|
|
788
1858
|
home: `import React from "react";
|
|
789
|
-
import { Button, Text
|
|
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
|
-
<
|
|
1867
|
+
<ScreenLayout>
|
|
797
1868
|
<Text>Welcome Home!</Text>
|
|
798
1869
|
<Button title="Logout" onPress={() => void logout()} />
|
|
799
|
-
</
|
|
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,
|
|
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
|
-
|
|
820
|
-
|
|
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
|
-
<
|
|
825
|
-
<TextInput
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
1954
|
+
<ScreenLayout>
|
|
847
1955
|
<Text>Welcome Home!</Text>
|
|
848
1956
|
<Button title="Logout" onPress={() => void logout()} />
|
|
849
|
-
</
|
|
1957
|
+
</ScreenLayout>
|
|
850
1958
|
);
|
|
851
1959
|
}
|
|
852
1960
|
`,
|
|
@@ -854,49 +1962,74 @@ export default function HomeScreen() {
|
|
|
854
1962
|
settings,
|
|
855
1963
|
};
|
|
856
1964
|
}
|
|
857
|
-
|
|
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 {
|
|
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
|
-
<
|
|
868
|
-
<
|
|
869
|
-
|
|
870
|
-
</
|
|
871
|
-
</
|
|
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
|
|
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
|
-
<
|
|
884
|
-
<
|
|
885
|
-
|
|
886
|
-
</
|
|
887
|
-
</
|
|
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
|
|
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
|
-
<
|
|
898
|
-
|
|
899
|
-
</
|
|
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";
|