whoosh 1.5.0 → 1.7.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.
- checksums.yaml +4 -4
- data/README.md +101 -47
- data/lib/whoosh/app.rb +3 -2
- data/lib/whoosh/cli/client_generator.rb +237 -0
- data/lib/whoosh/cli/main.rb +10 -0
- data/lib/whoosh/client_gen/base_generator.rb +84 -0
- data/lib/whoosh/client_gen/dependency_checker.rb +49 -0
- data/lib/whoosh/client_gen/fallback_backend.rb +292 -0
- data/lib/whoosh/client_gen/generators/expo.rb +1038 -0
- data/lib/whoosh/client_gen/generators/flutter.rb +915 -0
- data/lib/whoosh/client_gen/generators/htmx.rb +498 -0
- data/lib/whoosh/client_gen/generators/ios.rb +832 -0
- data/lib/whoosh/client_gen/generators/react_spa.rb +932 -0
- data/lib/whoosh/client_gen/generators/telegram_bot.rb +624 -0
- data/lib/whoosh/client_gen/generators/telegram_mini_app.rb +844 -0
- data/lib/whoosh/client_gen/introspector.rb +178 -0
- data/lib/whoosh/client_gen/ir.rb +37 -0
- data/lib/whoosh/version.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "whoosh/client_gen/base_generator"
|
|
5
|
+
|
|
6
|
+
module Whoosh
|
|
7
|
+
module ClientGen
|
|
8
|
+
module Generators
|
|
9
|
+
class Expo < BaseGenerator
|
|
10
|
+
def generate
|
|
11
|
+
generate_config_files
|
|
12
|
+
generate_auth_store
|
|
13
|
+
generate_api_client
|
|
14
|
+
generate_auth_api if ir.has_auth?
|
|
15
|
+
ir.resources.each do |resource|
|
|
16
|
+
generate_model(resource)
|
|
17
|
+
generate_resource_api(resource)
|
|
18
|
+
generate_resource_hook(resource)
|
|
19
|
+
generate_resource_screens(resource)
|
|
20
|
+
end
|
|
21
|
+
generate_auth_hook if ir.has_auth?
|
|
22
|
+
generate_root_layout
|
|
23
|
+
generate_app_layout
|
|
24
|
+
generate_auth_screens if ir.has_auth?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# ── Config files ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def generate_config_files
|
|
32
|
+
write_file("package.json", package_json)
|
|
33
|
+
write_file("app.json", app_json)
|
|
34
|
+
write_file("tsconfig.json", tsconfig_json)
|
|
35
|
+
write_file(".env", dot_env)
|
|
36
|
+
write_file(".gitignore", gitignore)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def package_json
|
|
40
|
+
pkg = {
|
|
41
|
+
"name" => "app",
|
|
42
|
+
"version" => "1.0.0",
|
|
43
|
+
"main" => "expo-router/entry",
|
|
44
|
+
"scripts" => {
|
|
45
|
+
"start" => "expo start",
|
|
46
|
+
"android" => "expo start --android",
|
|
47
|
+
"ios" => "expo start --ios",
|
|
48
|
+
"web" => "expo start --web"
|
|
49
|
+
},
|
|
50
|
+
"dependencies" => {
|
|
51
|
+
"expo" => "~52.0.0",
|
|
52
|
+
"expo-router" => "~4.0.0",
|
|
53
|
+
"expo-secure-store" => "~14.0.0",
|
|
54
|
+
"expo-status-bar" => "~2.0.0",
|
|
55
|
+
"react" => "19.0.0",
|
|
56
|
+
"react-native" => "0.76.0",
|
|
57
|
+
"@react-native-async-storage/async-storage" => "^2.0.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies" => {
|
|
60
|
+
"@babel/core" => "^7.25.0",
|
|
61
|
+
"@types/react" => "~19.0.0",
|
|
62
|
+
"@types/react-native" => "~0.76.0",
|
|
63
|
+
"typescript" => "^5.3.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
JSON.pretty_generate(pkg) + "\n"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def app_json
|
|
70
|
+
config = {
|
|
71
|
+
"expo" => {
|
|
72
|
+
"name" => "App",
|
|
73
|
+
"slug" => "app",
|
|
74
|
+
"version" => "1.0.0",
|
|
75
|
+
"orientation" => "portrait",
|
|
76
|
+
"scheme" => "app",
|
|
77
|
+
"userInterfaceStyle" => "automatic",
|
|
78
|
+
"assetBundlePatterns" => ["**/*"],
|
|
79
|
+
"ios" => { "supportsTablet" => true },
|
|
80
|
+
"android" => { "adaptiveIcon" => { "backgroundColor" => "#ffffff" } },
|
|
81
|
+
"web" => { "bundler" => "metro" },
|
|
82
|
+
"plugins" => ["expo-router", "expo-secure-store"]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
JSON.pretty_generate(config) + "\n"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tsconfig_json
|
|
89
|
+
<<~JSON
|
|
90
|
+
{
|
|
91
|
+
"extends": "expo/tsconfig.base",
|
|
92
|
+
"compilerOptions": {
|
|
93
|
+
"strict": true,
|
|
94
|
+
"paths": {
|
|
95
|
+
"@/*": ["./*"]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
JSON
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def dot_env
|
|
103
|
+
"EXPO_PUBLIC_API_URL=#{ir.base_url}\n"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def gitignore
|
|
107
|
+
<<~TXT
|
|
108
|
+
node_modules
|
|
109
|
+
.expo
|
|
110
|
+
dist
|
|
111
|
+
.env.local
|
|
112
|
+
TXT
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ── Auth store (SecureStore) ───────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def generate_auth_store
|
|
118
|
+
write_file("src/store/auth.ts", <<~TS)
|
|
119
|
+
import * as SecureStore from "expo-secure-store";
|
|
120
|
+
|
|
121
|
+
const ACCESS_TOKEN_KEY = "access_token";
|
|
122
|
+
const REFRESH_TOKEN_KEY = "refresh_token";
|
|
123
|
+
|
|
124
|
+
export async function getAccessToken(): Promise<string | null> {
|
|
125
|
+
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function getRefreshToken(): Promise<string | null> {
|
|
129
|
+
return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function setTokens(access: string, refresh?: string): Promise<void> {
|
|
133
|
+
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, access);
|
|
134
|
+
if (refresh) {
|
|
135
|
+
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refresh);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function clearTokens(): Promise<void> {
|
|
140
|
+
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
|
|
141
|
+
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
|
|
142
|
+
}
|
|
143
|
+
TS
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ── API client ────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def generate_api_client
|
|
149
|
+
write_file("src/api/client.ts", <<~TS)
|
|
150
|
+
import { getAccessToken, getRefreshToken, setTokens } from "../store/auth";
|
|
151
|
+
|
|
152
|
+
const API_URL = process.env.EXPO_PUBLIC_API_URL || "#{ir.base_url}";
|
|
153
|
+
|
|
154
|
+
async function refreshAccessToken(): Promise<boolean> {
|
|
155
|
+
const refresh = await getRefreshToken();
|
|
156
|
+
if (!refresh) return false;
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetch(`${API_URL}/auth/refresh`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "Content-Type": "application/json" },
|
|
161
|
+
body: JSON.stringify({ refresh_token: refresh }),
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) return false;
|
|
164
|
+
const data = await res.json();
|
|
165
|
+
await setTokens(data.access_token, data.refresh_token);
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function apiRequest<T = any>(
|
|
173
|
+
path: string,
|
|
174
|
+
options: RequestInit = {}
|
|
175
|
+
): Promise<T> {
|
|
176
|
+
const access = await getAccessToken();
|
|
177
|
+
const headers: Record<string, string> = {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
...(options.headers as Record<string, string>),
|
|
180
|
+
};
|
|
181
|
+
if (access) {
|
|
182
|
+
headers["Authorization"] = `Bearer ${access}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
|
186
|
+
|
|
187
|
+
if (res.status === 401) {
|
|
188
|
+
const refreshed = await refreshAccessToken();
|
|
189
|
+
if (refreshed) {
|
|
190
|
+
const newAccess = await getAccessToken();
|
|
191
|
+
headers["Authorization"] = `Bearer ${newAccess}`;
|
|
192
|
+
res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const err = await res.json().catch(() => ({}));
|
|
198
|
+
throw { status: res.status, ...err };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (res.status === 204) return undefined as T;
|
|
202
|
+
return res.json();
|
|
203
|
+
}
|
|
204
|
+
TS
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ── Auth API ──────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def generate_auth_api
|
|
210
|
+
write_file("src/api/auth.ts", <<~TS)
|
|
211
|
+
import { apiRequest } from "./client";
|
|
212
|
+
import { setTokens, clearTokens } from "../store/auth";
|
|
213
|
+
|
|
214
|
+
export async function login(email: string, password: string) {
|
|
215
|
+
const data = await apiRequest<{ access_token: string; refresh_token: string }>(
|
|
216
|
+
"/auth/login",
|
|
217
|
+
{ method: "POST", body: JSON.stringify({ email, password }) }
|
|
218
|
+
);
|
|
219
|
+
await setTokens(data.access_token, data.refresh_token);
|
|
220
|
+
return data;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function register(email: string, password: string) {
|
|
224
|
+
const data = await apiRequest<{ access_token: string; refresh_token: string }>(
|
|
225
|
+
"/auth/register",
|
|
226
|
+
{ method: "POST", body: JSON.stringify({ email, password }) }
|
|
227
|
+
);
|
|
228
|
+
await setTokens(data.access_token, data.refresh_token);
|
|
229
|
+
return data;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function logout() {
|
|
233
|
+
await apiRequest("/auth/logout", { method: "DELETE" });
|
|
234
|
+
await clearTokens();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function getMe() {
|
|
238
|
+
return apiRequest<{ id: string; email: string }>("/auth/me");
|
|
239
|
+
}
|
|
240
|
+
TS
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# ── Models ────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def generate_model(resource)
|
|
246
|
+
name = classify(resource.name)
|
|
247
|
+
singular = singularize(resource.name.to_s)
|
|
248
|
+
fields = resource.fields || []
|
|
249
|
+
|
|
250
|
+
lines = fields.map do |f|
|
|
251
|
+
fname = f[:name].to_s
|
|
252
|
+
ftype = type_for(f[:type])
|
|
253
|
+
" #{fname}: #{ftype};"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
create_lines = fields.select { |f| f[:required] }.map do |f|
|
|
257
|
+
" #{f[:name]}: #{type_for(f[:type])};"
|
|
258
|
+
end
|
|
259
|
+
create_optional = fields.reject { |f| f[:required] }.map do |f|
|
|
260
|
+
" #{f[:name]}?: #{type_for(f[:type])};"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
update_lines = fields.map do |f|
|
|
264
|
+
" #{f[:name]}?: #{type_for(f[:type])};"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
write_file("src/models/#{singular}.ts", <<~TS)
|
|
268
|
+
export interface #{name} {
|
|
269
|
+
id: string;
|
|
270
|
+
#{lines.join("\n")}
|
|
271
|
+
created_at?: string;
|
|
272
|
+
updated_at?: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface Create#{name}Input {
|
|
276
|
+
#{(create_lines + create_optional).join("\n")}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface Update#{name}Input {
|
|
280
|
+
#{update_lines.join("\n")}
|
|
281
|
+
}
|
|
282
|
+
TS
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# ── Resource API ──────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def generate_resource_api(resource)
|
|
288
|
+
plural = resource.name.to_s
|
|
289
|
+
singular = singularize(plural)
|
|
290
|
+
name = classify(resource.name)
|
|
291
|
+
has_pagination = resource.endpoints.any? { |e| e.pagination }
|
|
292
|
+
|
|
293
|
+
list_return = has_pagination ? "Promise<{ data: #{name}[]; cursor?: string }>" : "Promise<#{name}[]>"
|
|
294
|
+
list_params = has_pagination ? "cursor?: string" : ""
|
|
295
|
+
list_query = has_pagination ? 'const query = cursor ? `?cursor=${cursor}` : "";\n ' : ""
|
|
296
|
+
list_path = has_pagination ? "\"/#{plural}${query}\"" : "\"/#{plural}\""
|
|
297
|
+
|
|
298
|
+
write_file("src/api/#{plural}.ts", <<~TS)
|
|
299
|
+
import { apiRequest } from "./client";
|
|
300
|
+
import type { #{name}, Create#{name}Input, Update#{name}Input } from "../models/#{singular}";
|
|
301
|
+
|
|
302
|
+
export async function list#{name}s(#{list_params}): #{list_return} {
|
|
303
|
+
#{list_query}return apiRequest(#{list_path});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function get#{name}(id: string): Promise<#{name}> {
|
|
307
|
+
return apiRequest(`/#{plural}/${String("${id}")}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function create#{name}(input: Create#{name}Input): Promise<#{name}> {
|
|
311
|
+
return apiRequest("/#{plural}", {
|
|
312
|
+
method: "POST",
|
|
313
|
+
body: JSON.stringify(input),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function update#{name}(id: string, input: Update#{name}Input): Promise<#{name}> {
|
|
318
|
+
return apiRequest(`/#{plural}/${String("${id}")}`, {
|
|
319
|
+
method: "PUT",
|
|
320
|
+
body: JSON.stringify(input),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function delete#{name}(id: string): Promise<void> {
|
|
325
|
+
return apiRequest(`/#{plural}/${String("${id}")}`, { method: "DELETE" });
|
|
326
|
+
}
|
|
327
|
+
TS
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# ── Hooks ─────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
def generate_auth_hook
|
|
333
|
+
write_file("src/hooks/useAuth.ts", <<~TS)
|
|
334
|
+
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
|
335
|
+
import * as authApi from "../api/auth";
|
|
336
|
+
import { getAccessToken, clearTokens } from "../store/auth";
|
|
337
|
+
|
|
338
|
+
interface User {
|
|
339
|
+
id: string;
|
|
340
|
+
email: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface AuthContextValue {
|
|
344
|
+
user: User | null;
|
|
345
|
+
loading: boolean;
|
|
346
|
+
login: (email: string, password: string) => Promise<void>;
|
|
347
|
+
register: (email: string, password: string) => Promise<void>;
|
|
348
|
+
logout: () => Promise<void>;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export const AuthContext = createContext<AuthContextValue | null>(null);
|
|
352
|
+
|
|
353
|
+
export function useAuth(): AuthContextValue {
|
|
354
|
+
const ctx = useContext(AuthContext);
|
|
355
|
+
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
|
|
356
|
+
return ctx;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function useAuthProvider(): AuthContextValue {
|
|
360
|
+
const [user, setUser] = useState<User | null>(null);
|
|
361
|
+
const [loading, setLoading] = useState(true);
|
|
362
|
+
|
|
363
|
+
const fetchMe = useCallback(async () => {
|
|
364
|
+
try {
|
|
365
|
+
const me = await authApi.getMe();
|
|
366
|
+
setUser(me);
|
|
367
|
+
} catch {
|
|
368
|
+
await clearTokens();
|
|
369
|
+
setUser(null);
|
|
370
|
+
} finally {
|
|
371
|
+
setLoading(false);
|
|
372
|
+
}
|
|
373
|
+
}, []);
|
|
374
|
+
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
getAccessToken().then((token) => {
|
|
377
|
+
if (token) {
|
|
378
|
+
fetchMe();
|
|
379
|
+
} else {
|
|
380
|
+
setLoading(false);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}, [fetchMe]);
|
|
384
|
+
|
|
385
|
+
const login = async (email: string, password: string) => {
|
|
386
|
+
await authApi.login(email, password);
|
|
387
|
+
await fetchMe();
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const register = async (email: string, password: string) => {
|
|
391
|
+
await authApi.register(email, password);
|
|
392
|
+
await fetchMe();
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const logout = async () => {
|
|
396
|
+
await authApi.logout();
|
|
397
|
+
setUser(null);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return { user, loading, login, register, logout };
|
|
401
|
+
}
|
|
402
|
+
TS
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def generate_resource_hook(resource)
|
|
406
|
+
plural = resource.name.to_s
|
|
407
|
+
singular = singularize(plural)
|
|
408
|
+
name = classify(resource.name)
|
|
409
|
+
has_pagination = resource.endpoints.any? { |e| e.pagination }
|
|
410
|
+
|
|
411
|
+
write_file("src/hooks/use#{name}s.ts", <<~TS)
|
|
412
|
+
import { useState, useCallback } from "react";
|
|
413
|
+
import * as api from "../api/#{plural}";
|
|
414
|
+
import type { #{name}, Create#{name}Input, Update#{name}Input } from "../models/#{singular}";
|
|
415
|
+
|
|
416
|
+
export function use#{name}s() {
|
|
417
|
+
const [items, setItems] = useState<#{name}[]>([]);
|
|
418
|
+
const [loading, setLoading] = useState(false);
|
|
419
|
+
const [error, setError] = useState<string | null>(null);
|
|
420
|
+
#{has_pagination ? 'const [cursor, setCursor] = useState<string | undefined>();' : ''}
|
|
421
|
+
|
|
422
|
+
const fetchAll = useCallback(async (#{has_pagination ? 'nextCursor?: string' : ''}) => {
|
|
423
|
+
setLoading(true);
|
|
424
|
+
setError(null);
|
|
425
|
+
try {
|
|
426
|
+
#{if has_pagination
|
|
427
|
+
"const result = await api.list#{name}s(nextCursor);\n" \
|
|
428
|
+
" if (nextCursor) {\n" \
|
|
429
|
+
" setItems((prev) => [...prev, ...result.data]);\n" \
|
|
430
|
+
" } else {\n" \
|
|
431
|
+
" setItems(result.data);\n" \
|
|
432
|
+
" }\n" \
|
|
433
|
+
" setCursor(result.cursor);"
|
|
434
|
+
else
|
|
435
|
+
"const data = await api.list#{name}s();\n setItems(data);"
|
|
436
|
+
end}
|
|
437
|
+
} catch (e: any) {
|
|
438
|
+
setError(e.message || "Failed to load");
|
|
439
|
+
} finally {
|
|
440
|
+
setLoading(false);
|
|
441
|
+
}
|
|
442
|
+
}, []);
|
|
443
|
+
|
|
444
|
+
const create = async (input: Create#{name}Input) => {
|
|
445
|
+
const item = await api.create#{name}(input);
|
|
446
|
+
setItems((prev) => [...prev, item]);
|
|
447
|
+
return item;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const update = async (id: string, input: Update#{name}Input) => {
|
|
451
|
+
const item = await api.update#{name}(id, input);
|
|
452
|
+
setItems((prev) => prev.map((i) => (i.id === id ? item : i)));
|
|
453
|
+
return item;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const remove = async (id: string) => {
|
|
457
|
+
await api.delete#{name}(id);
|
|
458
|
+
setItems((prev) => prev.filter((i) => i.id !== id));
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
return { items, loading, error, fetchAll, create, update, remove#{has_pagination ? ', cursor' : ''} };
|
|
462
|
+
}
|
|
463
|
+
TS
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# ── Root layout ───────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
def generate_root_layout
|
|
469
|
+
write_file("app/_layout.tsx", <<~TSX)
|
|
470
|
+
import { useEffect } from "react";
|
|
471
|
+
import { Stack, useSegments, useRouter } from "expo-router";
|
|
472
|
+
import { AuthContext, useAuthProvider } from "../src/hooks/useAuth";
|
|
473
|
+
|
|
474
|
+
function RootLayoutNav() {
|
|
475
|
+
const { user, loading } = useAuthProvider();
|
|
476
|
+
const segments = useSegments();
|
|
477
|
+
const router = useRouter();
|
|
478
|
+
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (loading) return;
|
|
481
|
+
const inAuthGroup = segments[0] === "(auth)";
|
|
482
|
+
if (!user && !inAuthGroup) {
|
|
483
|
+
router.replace("/(auth)/login");
|
|
484
|
+
} else if (user && inAuthGroup) {
|
|
485
|
+
router.replace("/(app)");
|
|
486
|
+
}
|
|
487
|
+
}, [user, loading, segments]);
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<Stack screenOptions={{ headerShown: false }}>
|
|
491
|
+
<Stack.Screen name="(auth)" />
|
|
492
|
+
<Stack.Screen name="(app)" />
|
|
493
|
+
</Stack>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export default function RootLayout() {
|
|
498
|
+
const auth = useAuthProvider();
|
|
499
|
+
return (
|
|
500
|
+
<AuthContext.Provider value={auth}>
|
|
501
|
+
<RootLayoutNav />
|
|
502
|
+
</AuthContext.Provider>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
TSX
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# ── App layout ────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
def generate_app_layout
|
|
511
|
+
write_file("app/(app)/_layout.tsx", <<~TSX)
|
|
512
|
+
import { Stack } from "expo-router";
|
|
513
|
+
|
|
514
|
+
export default function AppLayout() {
|
|
515
|
+
return (
|
|
516
|
+
<Stack>
|
|
517
|
+
<Stack.Screen name="index" options={{ title: "Home" }} />
|
|
518
|
+
</Stack>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
TSX
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# ── Auth screens ──────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
def generate_auth_screens
|
|
527
|
+
generate_login_screen
|
|
528
|
+
generate_register_screen
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def generate_login_screen
|
|
532
|
+
write_file("app/(auth)/login.tsx", <<~TSX)
|
|
533
|
+
import { useState } from "react";
|
|
534
|
+
import {
|
|
535
|
+
View, Text, TextInput, Pressable, Alert, StyleSheet, KeyboardAvoidingView, Platform
|
|
536
|
+
} from "react-native";
|
|
537
|
+
import { useRouter, Link } from "expo-router";
|
|
538
|
+
import { useAuth } from "../../src/hooks/useAuth";
|
|
539
|
+
|
|
540
|
+
export default function LoginScreen() {
|
|
541
|
+
const { login } = useAuth();
|
|
542
|
+
const router = useRouter();
|
|
543
|
+
const [email, setEmail] = useState("");
|
|
544
|
+
const [password, setPassword] = useState("");
|
|
545
|
+
const [loading, setLoading] = useState(false);
|
|
546
|
+
|
|
547
|
+
const handleLogin = async () => {
|
|
548
|
+
if (!email || !password) {
|
|
549
|
+
Alert.alert("Error", "Please fill in all fields");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
setLoading(true);
|
|
553
|
+
try {
|
|
554
|
+
await login(email, password);
|
|
555
|
+
router.replace("/(app)");
|
|
556
|
+
} catch (err: any) {
|
|
557
|
+
Alert.alert("Login Failed", err.message || "Invalid credentials");
|
|
558
|
+
} finally {
|
|
559
|
+
setLoading(false);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
return (
|
|
564
|
+
<KeyboardAvoidingView
|
|
565
|
+
style={styles.container}
|
|
566
|
+
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
567
|
+
>
|
|
568
|
+
<View style={styles.form}>
|
|
569
|
+
<Text style={styles.title}>Login</Text>
|
|
570
|
+
<TextInput
|
|
571
|
+
style={styles.input}
|
|
572
|
+
placeholder="Email"
|
|
573
|
+
value={email}
|
|
574
|
+
onChangeText={setEmail}
|
|
575
|
+
autoCapitalize="none"
|
|
576
|
+
keyboardType="email-address"
|
|
577
|
+
/>
|
|
578
|
+
<TextInput
|
|
579
|
+
style={styles.input}
|
|
580
|
+
placeholder="Password"
|
|
581
|
+
value={password}
|
|
582
|
+
onChangeText={setPassword}
|
|
583
|
+
secureTextEntry
|
|
584
|
+
/>
|
|
585
|
+
<Pressable style={styles.button} onPress={handleLogin} disabled={loading}>
|
|
586
|
+
<Text style={styles.buttonText}>{loading ? "Logging in..." : "Login"}</Text>
|
|
587
|
+
</Pressable>
|
|
588
|
+
<Link href="/(auth)/register" style={styles.link}>
|
|
589
|
+
Don't have an account? Register
|
|
590
|
+
</Link>
|
|
591
|
+
</View>
|
|
592
|
+
</KeyboardAvoidingView>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const styles = StyleSheet.create({
|
|
597
|
+
container: { flex: 1, backgroundColor: "#fff" },
|
|
598
|
+
form: { flex: 1, justifyContent: "center", padding: 24, gap: 16 },
|
|
599
|
+
title: { fontSize: 28, fontWeight: "700", marginBottom: 8 },
|
|
600
|
+
input: {
|
|
601
|
+
borderWidth: 1, borderColor: "#ddd", borderRadius: 8,
|
|
602
|
+
padding: 12, fontSize: 16,
|
|
603
|
+
},
|
|
604
|
+
button: {
|
|
605
|
+
backgroundColor: "#0066cc", borderRadius: 8,
|
|
606
|
+
padding: 14, alignItems: "center",
|
|
607
|
+
},
|
|
608
|
+
buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
|
609
|
+
link: { color: "#0066cc", textAlign: "center", marginTop: 8 },
|
|
610
|
+
});
|
|
611
|
+
TSX
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def generate_register_screen
|
|
615
|
+
write_file("app/(auth)/register.tsx", <<~TSX)
|
|
616
|
+
import { useState } from "react";
|
|
617
|
+
import {
|
|
618
|
+
View, Text, TextInput, Pressable, Alert, StyleSheet, KeyboardAvoidingView, Platform
|
|
619
|
+
} from "react-native";
|
|
620
|
+
import { useRouter, Link } from "expo-router";
|
|
621
|
+
import { useAuth } from "../../src/hooks/useAuth";
|
|
622
|
+
|
|
623
|
+
export default function RegisterScreen() {
|
|
624
|
+
const { register } = useAuth();
|
|
625
|
+
const router = useRouter();
|
|
626
|
+
const [email, setEmail] = useState("");
|
|
627
|
+
const [password, setPassword] = useState("");
|
|
628
|
+
const [loading, setLoading] = useState(false);
|
|
629
|
+
|
|
630
|
+
const handleRegister = async () => {
|
|
631
|
+
if (!email || !password) {
|
|
632
|
+
Alert.alert("Error", "Please fill in all fields");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
setLoading(true);
|
|
636
|
+
try {
|
|
637
|
+
await register(email, password);
|
|
638
|
+
router.replace("/(app)");
|
|
639
|
+
} catch (err: any) {
|
|
640
|
+
Alert.alert("Registration Failed", err.message || "Could not register");
|
|
641
|
+
} finally {
|
|
642
|
+
setLoading(false);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
return (
|
|
647
|
+
<KeyboardAvoidingView
|
|
648
|
+
style={styles.container}
|
|
649
|
+
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
650
|
+
>
|
|
651
|
+
<View style={styles.form}>
|
|
652
|
+
<Text style={styles.title}>Register</Text>
|
|
653
|
+
<TextInput
|
|
654
|
+
style={styles.input}
|
|
655
|
+
placeholder="Email"
|
|
656
|
+
value={email}
|
|
657
|
+
onChangeText={setEmail}
|
|
658
|
+
autoCapitalize="none"
|
|
659
|
+
keyboardType="email-address"
|
|
660
|
+
/>
|
|
661
|
+
<TextInput
|
|
662
|
+
style={styles.input}
|
|
663
|
+
placeholder="Password"
|
|
664
|
+
value={password}
|
|
665
|
+
onChangeText={setPassword}
|
|
666
|
+
secureTextEntry
|
|
667
|
+
/>
|
|
668
|
+
<Pressable style={styles.button} onPress={handleRegister} disabled={loading}>
|
|
669
|
+
<Text style={styles.buttonText}>{loading ? "Registering..." : "Register"}</Text>
|
|
670
|
+
</Pressable>
|
|
671
|
+
<Link href="/(auth)/login" style={styles.link}>
|
|
672
|
+
Already have an account? Login
|
|
673
|
+
</Link>
|
|
674
|
+
</View>
|
|
675
|
+
</KeyboardAvoidingView>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const styles = StyleSheet.create({
|
|
680
|
+
container: { flex: 1, backgroundColor: "#fff" },
|
|
681
|
+
form: { flex: 1, justifyContent: "center", padding: 24, gap: 16 },
|
|
682
|
+
title: { fontSize: 28, fontWeight: "700", marginBottom: 8 },
|
|
683
|
+
input: {
|
|
684
|
+
borderWidth: 1, borderColor: "#ddd", borderRadius: 8,
|
|
685
|
+
padding: 12, fontSize: 16,
|
|
686
|
+
},
|
|
687
|
+
button: {
|
|
688
|
+
backgroundColor: "#0066cc", borderRadius: 8,
|
|
689
|
+
padding: 14, alignItems: "center",
|
|
690
|
+
},
|
|
691
|
+
buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
|
692
|
+
link: { color: "#0066cc", textAlign: "center", marginTop: 8 },
|
|
693
|
+
});
|
|
694
|
+
TSX
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# ── Resource screens ──────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
def generate_resource_screens(resource)
|
|
700
|
+
plural = resource.name.to_s
|
|
701
|
+
singular = singularize(plural)
|
|
702
|
+
name = classify(resource.name)
|
|
703
|
+
fields = resource.fields || []
|
|
704
|
+
has_pagination = resource.endpoints.any? { |e| e.pagination }
|
|
705
|
+
|
|
706
|
+
generate_index_screen(name, plural, singular, fields, has_pagination)
|
|
707
|
+
generate_detail_screen(name, plural, singular, fields)
|
|
708
|
+
generate_form_screen(name, plural, singular, fields)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def generate_index_screen(name, plural, singular, fields, has_pagination)
|
|
712
|
+
display_fields = fields.first(2)
|
|
713
|
+
field_texts = display_fields.map do |f|
|
|
714
|
+
" <Text style={styles.itemSubtext}>{String(item.#{f[:name]} ?? \"\")}</Text>"
|
|
715
|
+
end.join("\n")
|
|
716
|
+
|
|
717
|
+
write_file("app/(app)/#{plural}/index.tsx", <<~TSX)
|
|
718
|
+
import { useEffect, useCallback } from "react";
|
|
719
|
+
import {
|
|
720
|
+
View, Text, FlatList, Pressable, StyleSheet, ActivityIndicator, RefreshControl
|
|
721
|
+
} from "react-native";
|
|
722
|
+
import { useRouter } from "expo-router";
|
|
723
|
+
import { use#{name}s } from "../../../src/hooks/use#{name}s";
|
|
724
|
+
import type { #{name} } from "../../../src/models/#{singular}";
|
|
725
|
+
|
|
726
|
+
export default function #{name}ListScreen() {
|
|
727
|
+
const { items, loading, error, fetchAll, remove#{has_pagination ? ', cursor' : ''} } = use#{name}s();
|
|
728
|
+
const router = useRouter();
|
|
729
|
+
|
|
730
|
+
useEffect(() => { fetchAll(); }, []);
|
|
731
|
+
|
|
732
|
+
const onRefresh = useCallback(() => { fetchAll(); }, [fetchAll]);
|
|
733
|
+
|
|
734
|
+
const renderItem = ({ item }: { item: #{name} }) => (
|
|
735
|
+
<Pressable
|
|
736
|
+
style={styles.item}
|
|
737
|
+
onPress={() => router.push(`/(app)/#{plural}/${String("${item.id}")}`)}
|
|
738
|
+
>
|
|
739
|
+
<View style={styles.itemContent}>
|
|
740
|
+
<Text style={styles.itemTitle}>{item.id}</Text>
|
|
741
|
+
#{field_texts}
|
|
742
|
+
</View>
|
|
743
|
+
<Pressable onPress={() => remove(item.id)} style={styles.deleteButton}>
|
|
744
|
+
<Text style={styles.deleteText}>Delete</Text>
|
|
745
|
+
</Pressable>
|
|
746
|
+
</Pressable>
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
if (loading && items.length === 0) {
|
|
750
|
+
return (
|
|
751
|
+
<View style={styles.center}>
|
|
752
|
+
<ActivityIndicator size="large" />
|
|
753
|
+
</View>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (error) {
|
|
758
|
+
return (
|
|
759
|
+
<View style={styles.center}>
|
|
760
|
+
<Text style={styles.error}>{error}</Text>
|
|
761
|
+
</View>
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return (
|
|
766
|
+
<View style={styles.container}>
|
|
767
|
+
<Pressable style={styles.createButton} onPress={() => router.push("/(app)/#{plural}/form")}>
|
|
768
|
+
<Text style={styles.createButtonText}>+ New #{name}</Text>
|
|
769
|
+
</Pressable>
|
|
770
|
+
<FlatList
|
|
771
|
+
data={items}
|
|
772
|
+
keyExtractor={(item) => item.id}
|
|
773
|
+
renderItem={renderItem}
|
|
774
|
+
refreshControl={<RefreshControl refreshing={loading} onRefresh={onRefresh} />}
|
|
775
|
+
#{has_pagination ? 'onEndReached={() => cursor && fetchAll(cursor)}' : ''}
|
|
776
|
+
#{has_pagination ? 'onEndReachedThreshold={0.5}' : ''}
|
|
777
|
+
contentContainerStyle={styles.list}
|
|
778
|
+
/>
|
|
779
|
+
</View>
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const styles = StyleSheet.create({
|
|
784
|
+
container: { flex: 1, backgroundColor: "#f5f5f5" },
|
|
785
|
+
center: { flex: 1, justifyContent: "center", alignItems: "center" },
|
|
786
|
+
list: { padding: 16, gap: 8 },
|
|
787
|
+
createButton: {
|
|
788
|
+
margin: 16, backgroundColor: "#0066cc", borderRadius: 8,
|
|
789
|
+
padding: 12, alignItems: "center",
|
|
790
|
+
},
|
|
791
|
+
createButtonText: { color: "#fff", fontWeight: "600", fontSize: 16 },
|
|
792
|
+
item: {
|
|
793
|
+
backgroundColor: "#fff", borderRadius: 8, padding: 16,
|
|
794
|
+
flexDirection: "row", alignItems: "center",
|
|
795
|
+
shadowColor: "#000", shadowOffset: { width: 0, height: 1 },
|
|
796
|
+
shadowOpacity: 0.1, shadowRadius: 2, elevation: 2,
|
|
797
|
+
},
|
|
798
|
+
itemContent: { flex: 1 },
|
|
799
|
+
itemTitle: { fontSize: 16, fontWeight: "600" },
|
|
800
|
+
itemSubtext: { fontSize: 14, color: "#666", marginTop: 2 },
|
|
801
|
+
deleteButton: { padding: 8 },
|
|
802
|
+
deleteText: { color: "#cc0000", fontWeight: "500" },
|
|
803
|
+
error: { color: "#cc0000", fontSize: 16 },
|
|
804
|
+
});
|
|
805
|
+
TSX
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def generate_detail_screen(name, plural, singular, fields)
|
|
809
|
+
field_rows = fields.map do |f|
|
|
810
|
+
<<~TSX.chomp
|
|
811
|
+
<View style={styles.row}>
|
|
812
|
+
<Text style={styles.label}>#{camelize(f[:name].to_s)}</Text>
|
|
813
|
+
<Text style={styles.value}>{String(item.#{f[:name]} ?? "—")}</Text>
|
|
814
|
+
</View>
|
|
815
|
+
TSX
|
|
816
|
+
end.join("\n")
|
|
817
|
+
|
|
818
|
+
write_file("app/(app)/#{plural}/[id].tsx", <<~TSX)
|
|
819
|
+
import { useEffect, useState } from "react";
|
|
820
|
+
import {
|
|
821
|
+
View, Text, Pressable, StyleSheet, ActivityIndicator, Alert, ScrollView
|
|
822
|
+
} from "react-native";
|
|
823
|
+
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
824
|
+
import { get#{name}, delete#{name} } from "../../../src/api/#{plural}";
|
|
825
|
+
import type { #{name} } from "../../../src/models/#{singular}";
|
|
826
|
+
|
|
827
|
+
export default function #{name}DetailScreen() {
|
|
828
|
+
const { id } = useLocalSearchParams<{ id: string }>();
|
|
829
|
+
const router = useRouter();
|
|
830
|
+
const [item, setItem] = useState<#{name} | null>(null);
|
|
831
|
+
const [loading, setLoading] = useState(true);
|
|
832
|
+
|
|
833
|
+
useEffect(() => {
|
|
834
|
+
if (id) {
|
|
835
|
+
get#{name}(id)
|
|
836
|
+
.then(setItem)
|
|
837
|
+
.finally(() => setLoading(false));
|
|
838
|
+
}
|
|
839
|
+
}, [id]);
|
|
840
|
+
|
|
841
|
+
const handleDelete = () => {
|
|
842
|
+
Alert.alert("Confirm Delete", "Are you sure you want to delete this item?", [
|
|
843
|
+
{ text: "Cancel", style: "cancel" },
|
|
844
|
+
{
|
|
845
|
+
text: "Delete", style: "destructive",
|
|
846
|
+
onPress: async () => {
|
|
847
|
+
if (id) {
|
|
848
|
+
await delete#{name}(id);
|
|
849
|
+
router.back();
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
]);
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
if (loading) {
|
|
857
|
+
return (
|
|
858
|
+
<View style={styles.center}>
|
|
859
|
+
<ActivityIndicator size="large" />
|
|
860
|
+
</View>
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (!item) {
|
|
865
|
+
return (
|
|
866
|
+
<View style={styles.center}>
|
|
867
|
+
<Text>Item not found</Text>
|
|
868
|
+
</View>
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return (
|
|
873
|
+
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
874
|
+
#{field_rows}
|
|
875
|
+
<View style={styles.actions}>
|
|
876
|
+
<Pressable
|
|
877
|
+
style={styles.editButton}
|
|
878
|
+
onPress={() => router.push(`/(app)/#{plural}/form?id=${String("${item.id}")}`)}
|
|
879
|
+
>
|
|
880
|
+
<Text style={styles.editButtonText}>Edit</Text>
|
|
881
|
+
</Pressable>
|
|
882
|
+
<Pressable style={styles.deleteButton} onPress={handleDelete}>
|
|
883
|
+
<Text style={styles.deleteButtonText}>Delete</Text>
|
|
884
|
+
</Pressable>
|
|
885
|
+
</View>
|
|
886
|
+
</ScrollView>
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const styles = StyleSheet.create({
|
|
891
|
+
container: { flex: 1, backgroundColor: "#fff" },
|
|
892
|
+
center: { flex: 1, justifyContent: "center", alignItems: "center" },
|
|
893
|
+
content: { padding: 24, gap: 16 },
|
|
894
|
+
row: { borderBottomWidth: 1, borderBottomColor: "#eee", paddingBottom: 12 },
|
|
895
|
+
label: { fontSize: 12, color: "#666", fontWeight: "500", textTransform: "uppercase" },
|
|
896
|
+
value: { fontSize: 16, marginTop: 4 },
|
|
897
|
+
actions: { flexDirection: "row", gap: 12, marginTop: 16 },
|
|
898
|
+
editButton: {
|
|
899
|
+
flex: 1, backgroundColor: "#0066cc", borderRadius: 8,
|
|
900
|
+
padding: 14, alignItems: "center",
|
|
901
|
+
},
|
|
902
|
+
editButtonText: { color: "#fff", fontWeight: "600" },
|
|
903
|
+
deleteButton: {
|
|
904
|
+
flex: 1, backgroundColor: "#cc0000", borderRadius: 8,
|
|
905
|
+
padding: 14, alignItems: "center",
|
|
906
|
+
},
|
|
907
|
+
deleteButtonText: { color: "#fff", fontWeight: "600" },
|
|
908
|
+
});
|
|
909
|
+
TSX
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def generate_form_screen(name, plural, singular, fields)
|
|
913
|
+
state_lines = fields.map do |f|
|
|
914
|
+
default_val = f[:default] ? "\"#{f[:default]}\"" : "\"\""
|
|
915
|
+
" const [#{f[:name]}, set#{camelize(f[:name].to_s)}] = useState(#{default_val});"
|
|
916
|
+
end.join("\n")
|
|
917
|
+
|
|
918
|
+
load_lines = fields.map do |f|
|
|
919
|
+
" set#{camelize(f[:name].to_s)}(String(data.#{f[:name]} ?? \"\"));"
|
|
920
|
+
end.join("\n")
|
|
921
|
+
|
|
922
|
+
input_fields = fields.map do |f|
|
|
923
|
+
fname = f[:name].to_s
|
|
924
|
+
setter = "set#{camelize(fname)}"
|
|
925
|
+
if f[:enum]
|
|
926
|
+
options = f[:enum].map do |v|
|
|
927
|
+
" <Pressable\n key=\"#{v}\"\n style={[styles.option, #{fname} === \"#{v}\" && styles.optionSelected]}\n onPress={() => #{setter}(\"#{v}\")}\n >\n <Text style={#{fname} === \"#{v}\" ? styles.optionTextSelected : styles.optionText}>#{v}</Text>\n </Pressable>"
|
|
928
|
+
end.join("\n")
|
|
929
|
+
<<~FIELD.chomp
|
|
930
|
+
<View style={styles.field}>
|
|
931
|
+
<Text style={styles.label}>#{camelize(fname)}</Text>
|
|
932
|
+
<View style={styles.options}>
|
|
933
|
+
#{options}
|
|
934
|
+
</View>
|
|
935
|
+
</View>
|
|
936
|
+
FIELD
|
|
937
|
+
else
|
|
938
|
+
<<~FIELD.chomp
|
|
939
|
+
<View style={styles.field}>
|
|
940
|
+
<Text style={styles.label}>#{camelize(fname)}</Text>
|
|
941
|
+
<TextInput
|
|
942
|
+
style={styles.input}
|
|
943
|
+
value={#{fname}}
|
|
944
|
+
onChangeText={#{setter}}
|
|
945
|
+
placeholder="Enter #{fname}"
|
|
946
|
+
/>
|
|
947
|
+
</View>
|
|
948
|
+
FIELD
|
|
949
|
+
end
|
|
950
|
+
end.join("\n ")
|
|
951
|
+
|
|
952
|
+
body_fields = fields.map { |f| "#{f[:name]}" }.join(", ")
|
|
953
|
+
|
|
954
|
+
write_file("app/(app)/#{plural}/form.tsx", <<~TSX)
|
|
955
|
+
import { useState, useEffect } from "react";
|
|
956
|
+
import {
|
|
957
|
+
View, Text, TextInput, Pressable, StyleSheet, Alert, ScrollView, ActivityIndicator
|
|
958
|
+
} from "react-native";
|
|
959
|
+
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
960
|
+
import { get#{name}, create#{name}, update#{name} } from "../../../src/api/#{plural}";
|
|
961
|
+
|
|
962
|
+
export default function #{name}FormScreen() {
|
|
963
|
+
const { id } = useLocalSearchParams<{ id?: string }>();
|
|
964
|
+
const router = useRouter();
|
|
965
|
+
const isEdit = Boolean(id);
|
|
966
|
+
const [loading, setLoading] = useState(false);
|
|
967
|
+
#{state_lines}
|
|
968
|
+
|
|
969
|
+
useEffect(() => {
|
|
970
|
+
if (id) {
|
|
971
|
+
get#{name}(id).then((data) => {
|
|
972
|
+
#{load_lines}
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}, [id]);
|
|
976
|
+
|
|
977
|
+
const handleSubmit = async () => {
|
|
978
|
+
setLoading(true);
|
|
979
|
+
try {
|
|
980
|
+
const body = { #{body_fields} };
|
|
981
|
+
if (isEdit && id) {
|
|
982
|
+
await update#{name}(id, body);
|
|
983
|
+
} else {
|
|
984
|
+
await create#{name}(body);
|
|
985
|
+
}
|
|
986
|
+
router.back();
|
|
987
|
+
} catch (err: any) {
|
|
988
|
+
Alert.alert("Error", err.message || "Save failed");
|
|
989
|
+
} finally {
|
|
990
|
+
setLoading(false);
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
return (
|
|
995
|
+
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
996
|
+
<Text style={styles.title}>{isEdit ? "Edit" : "New"} #{name}</Text>
|
|
997
|
+
#{input_fields}
|
|
998
|
+
<Pressable style={styles.button} onPress={handleSubmit} disabled={loading}>
|
|
999
|
+
{loading ? (
|
|
1000
|
+
<ActivityIndicator color="#fff" />
|
|
1001
|
+
) : (
|
|
1002
|
+
<Text style={styles.buttonText}>{isEdit ? "Update" : "Create"}</Text>
|
|
1003
|
+
)}
|
|
1004
|
+
</Pressable>
|
|
1005
|
+
</ScrollView>
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const styles = StyleSheet.create({
|
|
1010
|
+
container: { flex: 1, backgroundColor: "#fff" },
|
|
1011
|
+
content: { padding: 24, gap: 16 },
|
|
1012
|
+
title: { fontSize: 24, fontWeight: "700", marginBottom: 8 },
|
|
1013
|
+
field: { gap: 6 },
|
|
1014
|
+
label: { fontSize: 14, fontWeight: "500", color: "#333" },
|
|
1015
|
+
input: {
|
|
1016
|
+
borderWidth: 1, borderColor: "#ddd", borderRadius: 8,
|
|
1017
|
+
padding: 12, fontSize: 16,
|
|
1018
|
+
},
|
|
1019
|
+
options: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
|
1020
|
+
option: {
|
|
1021
|
+
borderWidth: 1, borderColor: "#ddd", borderRadius: 6,
|
|
1022
|
+
paddingHorizontal: 12, paddingVertical: 8,
|
|
1023
|
+
},
|
|
1024
|
+
optionSelected: { backgroundColor: "#0066cc", borderColor: "#0066cc" },
|
|
1025
|
+
optionText: { color: "#333" },
|
|
1026
|
+
optionTextSelected: { color: "#fff" },
|
|
1027
|
+
button: {
|
|
1028
|
+
backgroundColor: "#0066cc", borderRadius: 8,
|
|
1029
|
+
padding: 14, alignItems: "center", marginTop: 8,
|
|
1030
|
+
},
|
|
1031
|
+
buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
|
1032
|
+
});
|
|
1033
|
+
TSX
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
end
|