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.
@@ -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