codz-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +523 -2
  4. package/assets/gradle-wrapper/README.txt +2 -0
  5. package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
  6. package/assets/gradle-wrapper/gradlew +251 -0
  7. package/assets/gradle-wrapper/gradlew.bat +94 -0
  8. package/bin/codz.js +33 -0
  9. package/dist/adapters/android.js +321 -0
  10. package/dist/adapters/androidSetup.js +277 -0
  11. package/dist/adapters/common.js +114 -0
  12. package/dist/adapters/dartGen.js +83 -0
  13. package/dist/adapters/flutter.js +291 -0
  14. package/dist/adapters/flutterGen.js +204 -0
  15. package/dist/adapters/flutterScan.js +312 -0
  16. package/dist/adapters/flutterSetup.js +252 -0
  17. package/dist/adapters/flutterStyles.js +130 -0
  18. package/dist/adapters/generic.js +20 -0
  19. package/dist/adapters/getx.js +423 -0
  20. package/dist/adapters/index.js +28 -0
  21. package/dist/adapters/ios.js +440 -0
  22. package/dist/adapters/kotlinGen.js +110 -0
  23. package/dist/adapters/models.js +116 -0
  24. package/dist/adapters/nextjs.js +243 -0
  25. package/dist/adapters/nextjsGen.js +285 -0
  26. package/dist/adapters/nextjsScan.js +364 -0
  27. package/dist/adapters/nextjsSetup.js +119 -0
  28. package/dist/adapters/react.js +89 -0
  29. package/dist/adapters/reactNative.js +109 -0
  30. package/dist/adapters/reactNativeSetup.js +236 -0
  31. package/dist/adapters/reactQuery.js +89 -0
  32. package/dist/adapters/reactSetup.js +150 -0
  33. package/dist/adapters/source.js +165 -0
  34. package/dist/cli.js +1175 -0
  35. package/dist/core/cache.js +76 -0
  36. package/dist/core/changeset.js +404 -0
  37. package/dist/core/commands.js +239 -0
  38. package/dist/core/config.js +45 -0
  39. package/dist/core/diff.js +143 -0
  40. package/dist/core/events.js +41 -0
  41. package/dist/core/fsutil.js +122 -0
  42. package/dist/core/git.js +36 -0
  43. package/dist/core/jsonEdit.js +113 -0
  44. package/dist/core/merge.js +159 -0
  45. package/dist/core/proc.js +23 -0
  46. package/dist/core/safe.js +75 -0
  47. package/dist/core/templates.js +100 -0
  48. package/dist/core/tsAst.js +112 -0
  49. package/dist/core/types.js +12 -0
  50. package/dist/core/validateConfig.js +172 -0
  51. package/dist/core/workflow.js +72 -0
  52. package/dist/core/workspace.js +80 -0
  53. package/dist/core/yaml.js +126 -0
  54. package/dist/core/yamlDoc.js +367 -0
  55. package/dist/detector.js +95 -0
  56. package/dist/input/apiSpec.js +395 -0
  57. package/dist/input/curl.js +259 -0
  58. package/dist/input/endpoint.js +220 -0
  59. package/dist/input/har.js +120 -0
  60. package/dist/input/infer.js +174 -0
  61. package/dist/input/naming.js +145 -0
  62. package/dist/input/paste.js +130 -0
  63. package/dist/skills/androidSkeleton.js +292 -0
  64. package/dist/skills/androidTests.js +221 -0
  65. package/dist/skills/androidUi.js +561 -0
  66. package/dist/skills/androidViews.js +536 -0
  67. package/dist/skills/apiAdd.js +99 -0
  68. package/dist/skills/apiDiff.js +271 -0
  69. package/dist/skills/apiEdit.js +547 -0
  70. package/dist/skills/apiImport.js +127 -0
  71. package/dist/skills/apiList.js +35 -0
  72. package/dist/skills/apiSync.js +221 -0
  73. package/dist/skills/auth.js +1304 -0
  74. package/dist/skills/completion.js +142 -0
  75. package/dist/skills/config.js +93 -0
  76. package/dist/skills/create.js +358 -0
  77. package/dist/skills/doctor.js +304 -0
  78. package/dist/skills/doctorFix.js +214 -0
  79. package/dist/skills/explain.js +61 -0
  80. package/dist/skills/feature.js +418 -0
  81. package/dist/skills/featureEdit.js +225 -0
  82. package/dist/skills/featureLinks.js +179 -0
  83. package/dist/skills/init.js +66 -0
  84. package/dist/skills/iosUi.js +468 -0
  85. package/dist/skills/locale.js +335 -0
  86. package/dist/skills/mcp.js +175 -0
  87. package/dist/skills/mock.js +482 -0
  88. package/dist/skills/module.js +23 -0
  89. package/dist/skills/plans.js +66 -0
  90. package/dist/skills/prefill.js +84 -0
  91. package/dist/skills/reactNativeUi.js +388 -0
  92. package/dist/skills/reactUi.js +352 -0
  93. package/dist/skills/report.js +75 -0
  94. package/dist/skills/route.js +230 -0
  95. package/dist/skills/screen.js +597 -0
  96. package/dist/skills/screenForm.js +410 -0
  97. package/dist/skills/setup.js +28 -0
  98. package/dist/skills/setupDoctor.js +226 -0
  99. package/dist/skills/templatesCmd.js +32 -0
  100. package/dist/skills/testgen.js +331 -0
  101. package/dist/skills/theme.js +388 -0
  102. package/dist/starters/auth.js +144 -0
  103. package/dist/starters/commands.js +283 -0
  104. package/dist/starters/engine.js +315 -0
  105. package/dist/starters/home.js +42 -0
  106. package/dist/starters/http.js +93 -0
  107. package/dist/starters/manifest.js +285 -0
  108. package/dist/starters/paths.js +70 -0
  109. package/dist/starters/publish.js +150 -0
  110. package/dist/starters/registry.js +258 -0
  111. package/dist/starters/sign.js +72 -0
  112. package/dist/starters/source.js +98 -0
  113. package/dist/starters/strictJson.js +148 -0
  114. package/dist/starters/tar.js +180 -0
  115. package/docs/COMMANDS.md +527 -0
  116. package/examples/openapi.yaml +319 -0
  117. package/package.json +57 -4
@@ -0,0 +1,597 @@
1
+ // screen add <name> --api <endpoint> [--path /orders/:id]: a working screen in
2
+ // one change. The route, the page, the call to the endpoint, and the loading,
3
+ // error (with retry) and data states.
4
+ //
5
+ // GetX module (binding, controller, view, route) + api wire + an Obx view
6
+ // Flutter route add + a page that loads the endpoint in a FutureBuilder
7
+ // (riverpod: ConsumerStatefulWidget reading the provider; get_it: getIt<Repo>())
8
+ // Next.js route add + an async server page awaiting the endpoint, loading.tsx, error.tsx
9
+ //
10
+ // Path parameters feed the endpoint's arguments by name (--path /orders/:id → getOrder(id)).
11
+ import { join, posix } from 'node:path';
12
+ import { readMethod, splitParams } from "../adapters/getx.js";
13
+ import { addDartImport, importUri } from "../adapters/flutter.js";
14
+ import { importPath } from "../adapters/nextjs.js";
15
+ import { loadMap } from "../core/cache.js";
16
+ import { buildChangeSet } from "../core/changeset.js";
17
+ import { loadConfig } from "../core/config.js";
18
+ import { exists, readText } from "../core/fsutil.js";
19
+ import { ToolError } from "../core/types.js";
20
+ import { camel, kebab, pascal, snake } from "../input/naming.js";
21
+ import { findDeclaration } from "./apiEdit.js";
22
+ import { planModuleAdd } from "./module.js";
23
+ import { planRouteAdd } from "./route.js";
24
+ /** Dart: the text a Text widget shows for a field: a non-null String as it is, anything else interpolated (never a dead ?? on a field that turns out non-null). */
25
+ const dartShow = (expr, f) => (f && f.type === 'String' && !f.nullable ? expr : `'\${${expr}}'`);
26
+ const fieldOf = (m, name) => m?.fields.find((f) => f.name === name);
27
+ /** The model a response type names (User, User[], List<UserModel>, Promise<…>), and whether it is a list. */
28
+ export function responseShape(type, map) {
29
+ const raw = (type ?? '').replace(/^Promise<(.*)>$/, '$1').replace(/^Future<(.*)>$/, '$1').trim();
30
+ // Dart List<T>, TypeScript T[] / Array<T>, Swift [T].
31
+ const list = /^List<.+>$|\[\]$|^Array<.+>$|^\[[^\]:]+\]$/.test(raw);
32
+ const inner = raw.replace(/^List<(.+)>$/, '$1').replace(/^Array<(.+)>$/, '$1').replace(/\[\]$/, '').replace(/^\[([^\]:]+)\]$/, '$1').replace(/\?$/, '');
33
+ return { list, model: map.models.find((m) => m.name === inner), raw };
34
+ }
35
+ /** The field to show as a list item's title. */
36
+ export function titleField(model) {
37
+ if (!model?.fields.length)
38
+ return undefined;
39
+ const names = model.fields.map((f) => f.name);
40
+ return ['name', 'title', 'label', 'displayName', 'display_name', 'fullName', 'full_name', 'email', 'username', 'sku', 'id'].find((n) => names.includes(n)) ?? names[0];
41
+ }
42
+ export function idField(model) {
43
+ return model?.fields.find((f) => /^(id|uuid|key|slug)$/.test(f.name))?.name;
44
+ }
45
+ /** Required arguments of a TypeScript function declaration. */
46
+ export function tsArgs(params) {
47
+ const out = [];
48
+ let depth = 0;
49
+ let cur = '';
50
+ for (const ch of params) {
51
+ if ('{[(<'.includes(ch))
52
+ depth++;
53
+ if ('}])>'.includes(ch))
54
+ depth--;
55
+ if (ch === ',' && depth === 0) {
56
+ out.push(parseTsParam(cur));
57
+ cur = '';
58
+ }
59
+ else
60
+ cur += ch;
61
+ }
62
+ if (cur.trim())
63
+ out.push(parseTsParam(cur));
64
+ return out;
65
+ }
66
+ function parseTsParam(p) {
67
+ const m = /^\s*(\w+)(\?)?\s*:\s*([\s\S]+?)\s*(=.*)?$/.exec(p);
68
+ return m ? { name: m[1], type: m[3].trim(), required: !m[2] && !m[4] } : { name: p.trim(), type: 'unknown', required: true };
69
+ }
70
+ function dartArgs(params) {
71
+ const out = [];
72
+ // Retrofit annotations (@Path('id'), @Query('page'), @Body()) are not part of the type.
73
+ for (const part of splitParams(params.replace(/@\w+(?:\([^)]*\))?\s*/g, ''))) {
74
+ if (part.startsWith('{') || part.startsWith('[')) {
75
+ for (const np of splitParams(part.slice(1, -1))) {
76
+ const m = /^(required\s+)?([\w<>?, ]+?)\s+(\w+)\s*(=.*)?$/.exec(np.trim());
77
+ if (m)
78
+ out.push({ name: m[3], type: m[2].trim(), required: !!m[1], named: part.startsWith('{') });
79
+ }
80
+ }
81
+ else {
82
+ const m = /^([\w<>?, ]+?)\s+(\w+)$/.exec(part.trim());
83
+ if (m)
84
+ out.push({ name: m[2], type: m[1].trim(), required: true });
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ function findEndpoint(map, name) {
90
+ const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
91
+ if (!ep)
92
+ throw new ToolError('no_endpoint', `No endpoint called ${name} was found.`, map.endpoints.length ? `Endpoints: ${map.endpoints.map((e) => e.name).join(', ')}` : 'Add one first with: codz api add');
93
+ if (ep.method !== 'GET')
94
+ throw new ToolError('screen_needs_get', `${ep.name} is a ${ep.method} endpoint; a screen loads data with a GET.`, `Wire it into a screen's controller instead: codz api wire ${ep.name} --into <module>`);
95
+ return ep;
96
+ }
97
+ /** Route params available to the screen, from --path. */
98
+ export function routeParams(path) {
99
+ return [...(path ?? '').matchAll(/[:{]([\w-]+)\}?/g)].map((m) => camel(m[1]));
100
+ }
101
+ function missingArgsError(ep, missing, params, name) {
102
+ return new ToolError('screen_args', `${ep.name}() needs ${missing.map((a) => a.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: codz screen add ${name} --api ${ep.name} --path /${kebab(name)}/:${missing[0].name}${params.length ? ` (the route has ${params.join(', ')})` : ''}`);
103
+ }
104
+ // ---------------- GetX ----------------
105
+ async function getxScreen(root, adapter, map, opts) {
106
+ const ep = opts.api ? findEndpoint(map, opts.api) : undefined;
107
+ const cs = await planModuleAdd(root, adapter, { name: opts.name, initial: opts.initial, apis: ep ? [ep.name] : [] });
108
+ cs.skill = 'screen add';
109
+ if (!ep)
110
+ return cs;
111
+ const viewChange = cs.changes.find((c) => c.path.endsWith(`/views/${snake(opts.name)}_view.dart`));
112
+ const ctrlChange = cs.changes.find((c) => c.path.endsWith(`/controllers/${snake(opts.name)}_controller.dart`));
113
+ if (!viewChange?.after || !ctrlChange?.after)
114
+ return cs;
115
+ // The names api wire chose: getProducts → products / isProductsLoading / productsError / loadProducts().
116
+ const stripped = ep.name.replace(/^(get|fetch|load)(?=[A-Z])/, '');
117
+ const base = stripped !== ep.name ? camel(stripped) : ep.name;
118
+ const action = `load${pascal(base)}`;
119
+ const loading = `is${pascal(base)}Loading`;
120
+ const error = `${base}Error`;
121
+ const sig = readMethod(readText(join(root, ep.file)) ?? '', ep.name);
122
+ const shape = responseShape(sig?.returnType ?? ep.response, map);
123
+ const title = titleField(shape.model);
124
+ const needsArgs = sig?.hasRequired ?? false;
125
+ const I = ' ';
126
+ const dataLines = shape.list
127
+ ? [
128
+ `${I}if (controller.${base}.isEmpty) return const Center(child: Text('No items'));`,
129
+ `${I}return RefreshIndicator(`,
130
+ `${I} onRefresh: ${needsArgs ? `() async {}` : `controller.${action}`},`,
131
+ `${I} child: ListView.builder(`,
132
+ `${I} itemCount: controller.${base}.length,`,
133
+ `${I} itemBuilder: (context, index) {`,
134
+ `${I} final item = controller.${base}[index];`,
135
+ `${I} return ListTile(title: Text(${title ? dartShow(`item.${camel(title)}`, fieldOf(shape.model, title)) : `item.toString()`}));`,
136
+ `${I} },`,
137
+ `${I} ),`,
138
+ `${I});`,
139
+ ]
140
+ : [
141
+ `${I}final data = controller.${base}.value;`,
142
+ `${I}if (data == null) return const SizedBox.shrink();`,
143
+ `${I}return ListView(`,
144
+ `${I} children: [`,
145
+ ...(shape.model?.fields.length
146
+ ? shape.model.fields.map((f) => `${I} ListTile(title: const Text('${f.name}'), subtitle: Text(${dartShow(`data.${camel(f.name)}`, f)})),`)
147
+ : [`${I} ListTile(title: Text(data.toString())),`]),
148
+ `${I} ],`,
149
+ `${I});`,
150
+ ];
151
+ const body = [
152
+ ' body: Obx(() {',
153
+ `${I}if (controller.${loading}.value) return const Center(child: CircularProgressIndicator());`,
154
+ `${I}final error = controller.${error}.value;`,
155
+ `${I}if (error != null) {`,
156
+ `${I} return Center(`,
157
+ `${I} child: Column(`,
158
+ `${I} mainAxisSize: MainAxisSize.min,`,
159
+ `${I} children: [`,
160
+ `${I} Text(error, textAlign: TextAlign.center),`,
161
+ ...(needsArgs ? [] : [`${I} TextButton(onPressed: controller.${action}, child: const Text('Retry')),`]),
162
+ `${I} ],`,
163
+ `${I} ),`,
164
+ `${I} );`,
165
+ `${I}}`,
166
+ ...dataLines,
167
+ ' }),',
168
+ ].join('\n');
169
+ const view = viewChange.after.replace(/ {6}body: const Center\(child: Text\('\w+ is working'\)\),/, body);
170
+ viewChange.after = view;
171
+ cs.summary = cs.summary.filter((l) => !/^In \w+View: Obx/.test(l));
172
+ cs.summary.push(`Show ${base} in ${viewChange.path}: loading, error with retry, and the data`);
173
+ if (needsArgs) {
174
+ // Route parameters arrive in Get.parameters (Get.toNamed(Routes.X, parameters: {...})): load from onInit.
175
+ const args = dartArgs(sig?.params ?? '');
176
+ const params = routeParams(opts.path);
177
+ const required = args.filter((a) => a.required);
178
+ if (required.length && required.every((a) => params.includes(a.name)) && ctrlChange.after.includes(` Future<void> ${action}(`)) {
179
+ const conv = (a) => (/^int\??$/.test(a.type) ? `int.parse(${a.name})` : /^double\??$/.test(a.type) ? `double.parse(${a.name})` : a.name);
180
+ const call = required.map((a) => (a.named ? `${a.name}: ${conv(a)}` : conv(a))).join(', ');
181
+ const onInit = [' @override', ' void onInit() {', ' super.onInit();', ...required.map((a) => ` final ${a.name} = Get.parameters['${a.name}'];`), ` if (${required.map((a) => `${a.name} != null`).join(' && ')}) ${action}(${call});`, ' }', '', ''].join('\n');
182
+ ctrlChange.after = ctrlChange.after.replace(` Future<void> ${action}(`, `${onInit} Future<void> ${action}(`);
183
+ cs.summary.push(`${ctrlChange.path} reads ${required.map((a) => a.name).join(', ')} from Get.parameters and calls ${action}() on start`);
184
+ }
185
+ else {
186
+ cs.warnings.push(`${ep.name}() needs arguments, so ${action}() is not called on start. Call controller.${action}(...) with the values (for example from Get.parameters) in the controller's onInit.`);
187
+ }
188
+ }
189
+ return cs;
190
+ }
191
+ // ---------------- Flutter (riverpod, get_it) ----------------
192
+ async function flutterScreen(root, adapter, map, opts) {
193
+ const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
194
+ route.skill = 'screen add';
195
+ if (!opts.api)
196
+ return route;
197
+ const ep = findEndpoint(map, opts.api);
198
+ const c = map.conventions;
199
+ const page = route.changes.find((ch) => ch.before === null && ch.path.endsWith('.dart'));
200
+ if (!page?.after)
201
+ return route;
202
+ const cls = /class\s+(\w+)\s+extends\s+StatelessWidget/.exec(page.after)?.[1];
203
+ if (!cls)
204
+ return route;
205
+ const repoText = readText(join(root, ep.file)) ?? '';
206
+ const sig = readMethod(repoText, ep.name);
207
+ if (!sig?.repoClass)
208
+ throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
209
+ const args = dartArgs(sig.params);
210
+ const params = routeParams(opts.path);
211
+ const missing = args.filter((a) => a.required && !params.includes(a.name));
212
+ if (missing.length)
213
+ throw missingArgsError(ep, missing, params, opts.name);
214
+ const callArgs = args
215
+ .filter((a) => params.includes(a.name))
216
+ .map((a) => {
217
+ const v = /^int\??$/.test(a.type) ? `int.parse(widget.${a.name})` : /^double\??$/.test(a.type) ? `double.parse(widget.${a.name})` : `widget.${a.name}`;
218
+ return a.named ? `${a.name}: ${v}` : v;
219
+ })
220
+ .join(', ');
221
+ if (c.stateManagement === 'bloc')
222
+ return blocScreen(root, route, page, cls, ep, sig, args, params, map, opts);
223
+ const riverpod = c.di === 'riverpod';
224
+ const getIt = c.di === 'get_it';
225
+ if (!riverpod && !getIt)
226
+ throw new ToolError('not_supported', `screen add needs a way to get ${sig.repoClass} (Riverpod, get_it or GetX); this project registers it with "${c.di || 'nothing'}".`, `Use codz route add ${opts.name}, then load ${ep.name}() in the page yourself.`);
227
+ const providerName = `${camel(sig.repoClass)}Provider`;
228
+ const repoExpr = riverpod ? `ref.read(${providerName})` : `${c.diInstance || 'getIt'}<${sig.repoClass}>()`;
229
+ const shape = responseShape(sig.returnType, map);
230
+ const title = titleField(shape.model);
231
+ const fields = params.map((p) => ` final String ${p};\n`).join('');
232
+ const ctor = params.length ? `const ${cls}({super.key, ${params.map((p) => `required this.${p}`).join(', ')}});` : `const ${cls}({super.key});`;
233
+ const stateBase = riverpod ? 'ConsumerStatefulWidget' : 'StatefulWidget';
234
+ const stateCls = riverpod ? `ConsumerState<${cls}>` : `State<${cls}>`;
235
+ const titleText = page.after.match(/AppBar\(title: const Text\('([^']*)'\)\)/)?.[1] ?? pascal(opts.name);
236
+ const I = ' ';
237
+ const dataView = shape.list
238
+ ? [
239
+ `${I}final items = snapshot.data!;`,
240
+ `${I}if (items.isEmpty) return const Center(child: Text('No items'));`,
241
+ `${I}return RefreshIndicator(`,
242
+ `${I} onRefresh: () async => _reload(),`,
243
+ `${I} child: ListView.builder(`,
244
+ `${I} itemCount: items.length,`,
245
+ `${I} itemBuilder: (context, index) => ListTile(title: Text(${title ? dartShow(`items[index].${camel(title)}`, fieldOf(shape.model, title)) : 'items[index].toString()'})),`,
246
+ `${I} ),`,
247
+ `${I});`,
248
+ ]
249
+ : [
250
+ `${I}final data = snapshot.data${/\?$/.test(sig.returnType) ? '' : '!'};`,
251
+ ...(/\?$/.test(sig.returnType) ? [`${I}if (data == null) return const SizedBox.shrink();`] : []),
252
+ `${I}return ListView(`,
253
+ `${I} children: [`,
254
+ ...(shape.model?.fields.length ? shape.model.fields.map((f) => `${I} ListTile(title: const Text('${f.name}'), subtitle: Text(${dartShow(`data.${camel(f.name)}`, f)})),`) : [`${I} ListTile(title: Text(data.toString())),`]),
255
+ `${I} ],`,
256
+ `${I});`,
257
+ ];
258
+ let text = [
259
+ "import 'package:flutter/material.dart';",
260
+ '',
261
+ `class ${cls} extends ${stateBase} {`,
262
+ ` ${ctor}`,
263
+ ...(fields ? ['', fields.trimEnd()] : []),
264
+ '',
265
+ ' @override',
266
+ ` ${stateCls.replace(/<.*/, '')}<${cls}> createState() => _${cls}State();`,
267
+ '}',
268
+ '',
269
+ `class _${cls}State extends ${stateCls} {`,
270
+ ` late Future<${sig.returnType}> _future;`,
271
+ '',
272
+ ' @override',
273
+ ' void initState() {',
274
+ ' super.initState();',
275
+ ' _future = _load();',
276
+ ' }',
277
+ '',
278
+ ` Future<${sig.returnType}> _load() => ${repoExpr}.${ep.name}(${callArgs});`,
279
+ '',
280
+ ' void _reload() => setState(() => _future = _load());',
281
+ '',
282
+ ' @override',
283
+ ' Widget build(BuildContext context) {',
284
+ ' return Scaffold(',
285
+ ` appBar: AppBar(title: const Text('${titleText}')),`,
286
+ ` body: FutureBuilder<${sig.returnType}>(`,
287
+ ' future: _future,',
288
+ ' builder: (context, snapshot) {',
289
+ ' if (snapshot.connectionState != ConnectionState.done) {',
290
+ ' return const Center(child: CircularProgressIndicator());',
291
+ ' }',
292
+ ' if (snapshot.hasError) {',
293
+ ' return Center(',
294
+ ' child: Column(',
295
+ ' mainAxisSize: MainAxisSize.min,',
296
+ ' children: [',
297
+ " Text('${snapshot.error}', textAlign: TextAlign.center),",
298
+ " TextButton(onPressed: _reload, child: const Text('Retry')),",
299
+ ' ],',
300
+ ' ),',
301
+ ' );',
302
+ ' }',
303
+ ...dataView,
304
+ ' },',
305
+ ' ),',
306
+ ' );',
307
+ ' }',
308
+ '}',
309
+ '',
310
+ ].join('\n');
311
+ if (riverpod)
312
+ text = addDartImport(text, 'package:flutter_riverpod/flutter_riverpod.dart');
313
+ text = addDartImport(text, importUri(page.path, ep.file, c));
314
+ if (getIt && c.diFile)
315
+ text = addDartImport(text, importUri(page.path, c.diFile, c));
316
+ if (shape.model && shape.model.file !== ep.file)
317
+ text = addDartImport(text, importUri(page.path, shape.model.file, c));
318
+ page.after = text;
319
+ route.summary.push(`${page.path} loads ${ep.name}() and shows loading, error with retry, and the data`);
320
+ return route;
321
+ }
322
+ // ---------------- Flutter (Bloc) ----------------
323
+ /**
324
+ * A Cubit with sealed loading / loaded / failure states next to the page, and
325
+ * a page that provides it (BlocProvider) and switches on its state (BlocBuilder).
326
+ * The API class comes from get_it when the project uses it, otherwise from
327
+ * context.read (a RepositoryProvider above the page).
328
+ */
329
+ function blocScreen(root, route, page, cls, ep, sig, args, params, map, opts) {
330
+ const c = map.conventions;
331
+ if (/^void$/.test(sig.returnType))
332
+ throw new ToolError('screen_needs_data', `${ep.name}() returns nothing, so there is nothing for a screen to show.`);
333
+ const base = pascal(opts.name);
334
+ const [cubit, state] = [`${base}Cubit`, `${base}State`];
335
+ const [loading, loaded, failure] = [`${base}Loading`, `${base}Loaded`, `${base}Failure`];
336
+ const cubitFile = `${posix.dirname(page.path)}/${snake(opts.name)}_cubit.dart`;
337
+ const T = sig.returnType;
338
+ const conv = (a, v) => (/^int\??$/.test(a.type) ? `int.parse(${v})` : /^double\??$/.test(a.type) ? `double.parse(${v})` : v);
339
+ const callArgs = args
340
+ .filter((a) => params.includes(a.name))
341
+ .map((a) => (a.named ? `${a.name}: ${conv(a, a.name)}` : conv(a, a.name)))
342
+ .join(', ');
343
+ const getIt = c.di === 'get_it';
344
+ const repoExpr = getIt ? `${c.diInstance || 'getIt'}<${sig.repoClass}>()` : `context.read<${sig.repoClass}>()`;
345
+ if (!getIt)
346
+ route.warnings.push(`${page.path} reads ${sig.repoClass} with context.read, so provide it above this page: RepositoryProvider(create: (_) => ${sig.repoClass}(...), child: ...).`);
347
+ let cubitText = [
348
+ "import 'package:flutter_bloc/flutter_bloc.dart';",
349
+ '',
350
+ `sealed class ${state} {`,
351
+ ` const ${state}();`,
352
+ '}',
353
+ '',
354
+ `final class ${loading} extends ${state} {`,
355
+ ` const ${loading}();`,
356
+ '}',
357
+ '',
358
+ `final class ${loaded} extends ${state} {`,
359
+ ` const ${loaded}(this.data);`,
360
+ '',
361
+ ` final ${T} data;`,
362
+ '}',
363
+ '',
364
+ `final class ${failure} extends ${state} {`,
365
+ ` const ${failure}(this.message);`,
366
+ '',
367
+ ' final String message;',
368
+ '}',
369
+ '',
370
+ `class ${cubit} extends Cubit<${state}> {`,
371
+ ` ${cubit}(this._api${params.length ? `, {${params.map((p) => `required this.${p}`).join(', ')}}` : ''}) : super(const ${loading}());`,
372
+ '',
373
+ ` final ${sig.repoClass} _api;`,
374
+ ...params.map((p) => ` final String ${p};`),
375
+ '',
376
+ ' Future<void> load() async {',
377
+ ` emit(const ${loading}());`,
378
+ ' try {',
379
+ ` emit(${loaded}(await _api.${ep.name}(${callArgs})));`,
380
+ ' } catch (e) {',
381
+ ` emit(${failure}(e.toString()));`,
382
+ ' }',
383
+ ' }',
384
+ '}',
385
+ '',
386
+ ].join('\n');
387
+ cubitText = addDartImport(cubitText, importUri(cubitFile, ep.file, c));
388
+ const shape = responseShape(T, map);
389
+ if (shape.model && shape.model.file !== ep.file)
390
+ cubitText = addDartImport(cubitText, importUri(cubitFile, shape.model.file, c));
391
+ const title = titleField(shape.model);
392
+ const titleText = page.after.match(/AppBar\(title: const Text\('([^']*)'\)\)/)?.[1] ?? base;
393
+ const reload = `context.read<${cubit}>().load()`;
394
+ const dataView = shape.list
395
+ ? [
396
+ ` Widget _data(BuildContext context, ${T} items) {`,
397
+ " if (items.isEmpty) return const Center(child: Text('No items'));",
398
+ ' return RefreshIndicator(',
399
+ ` onRefresh: () => ${reload},`,
400
+ ' child: ListView.builder(',
401
+ ' itemCount: items.length,',
402
+ ` itemBuilder: (context, index) => ListTile(title: Text(${title ? dartShow(`items[index].${camel(title)}`, fieldOf(shape.model, title)) : 'items[index].toString()'})),`,
403
+ ' ),',
404
+ ' );',
405
+ ' }',
406
+ ]
407
+ : [
408
+ ` Widget _data(BuildContext context, ${T} data) {`,
409
+ ...(/\?$/.test(T) ? [' if (data == null) return const SizedBox.shrink();'] : []),
410
+ ' return ListView(',
411
+ ' children: [',
412
+ ...(shape.model?.fields.length ? shape.model.fields.map((f) => ` ListTile(title: const Text('${f.name}'), subtitle: Text(${dartShow(`data.${camel(f.name)}`, f)})),`) : [' ListTile(title: Text(data.toString())),']),
413
+ ' ],',
414
+ ' );',
415
+ ' }',
416
+ ];
417
+ const ctor = params.length ? `const ${cls}({super.key, ${params.map((p) => `required this.${p}`).join(', ')}});` : `const ${cls}({super.key});`;
418
+ let pageText = [
419
+ "import 'package:flutter/material.dart';",
420
+ "import 'package:flutter_bloc/flutter_bloc.dart';",
421
+ '',
422
+ `class ${cls} extends StatelessWidget {`,
423
+ ` ${ctor}`,
424
+ ...(params.length ? ['', ...params.map((p) => ` final String ${p};`)] : []),
425
+ '',
426
+ ' @override',
427
+ ' Widget build(BuildContext context) {',
428
+ ` return BlocProvider(`,
429
+ ` create: (context) => ${cubit}(${repoExpr}${params.length ? `, ${params.map((p) => `${p}: ${p}`).join(', ')}` : ''})..load(),`,
430
+ ' child: Scaffold(',
431
+ ` appBar: AppBar(title: const Text('${titleText}')),`,
432
+ ` body: BlocBuilder<${cubit}, ${state}>(`,
433
+ ' builder: (context, state) => switch (state) {',
434
+ ` ${loading}() => const Center(child: CircularProgressIndicator()),`,
435
+ ` ${failure}(:final message) => Center(`,
436
+ ' child: Column(',
437
+ ' mainAxisSize: MainAxisSize.min,',
438
+ ' children: [',
439
+ ' Text(message, textAlign: TextAlign.center),',
440
+ ` TextButton(onPressed: () => ${reload}, child: const Text('Retry')),`,
441
+ ' ],',
442
+ ' ),',
443
+ ' ),',
444
+ ` ${loaded}(:final data) => _data(context, data),`,
445
+ ' },',
446
+ ' ),',
447
+ ' ),',
448
+ ' );',
449
+ ' }',
450
+ '',
451
+ ...dataView,
452
+ '}',
453
+ '',
454
+ ].join('\n');
455
+ pageText = addDartImport(pageText, importUri(page.path, cubitFile, c));
456
+ if (getIt && c.diFile)
457
+ pageText = addDartImport(pageText, importUri(page.path, c.diFile, c));
458
+ else
459
+ pageText = addDartImport(pageText, importUri(page.path, ep.file, c));
460
+ if (shape.model && shape.model.file !== ep.file)
461
+ pageText = addDartImport(pageText, importUri(page.path, shape.model.file, c));
462
+ if (getIt)
463
+ pageText = addDartImport(pageText, importUri(page.path, ep.file, c));
464
+ page.after = pageText;
465
+ const files = new Map(route.changes.filter((ch) => ch.after !== null).map((ch) => [ch.path, ch.after]));
466
+ if (exists(join(root, cubitFile)))
467
+ throw new ToolError('file_exists', `${cubitFile} already exists.`, 'Pick another screen name.');
468
+ files.set(cubitFile, cubitText);
469
+ const summary = [...route.summary, `Create ${cubitFile}: ${cubit} with loading, loaded and failure states, calling ${sig.repoClass}.${ep.name}()`, `${page.path} provides ${cubit} and shows loading, error with retry, and the data`];
470
+ return buildChangeSet(root, 'screen add', { files, summary, warnings: route.warnings, generated: [] });
471
+ }
472
+ // ---------------- Next.js ----------------
473
+ async function nextScreen(root, adapter, map, opts) {
474
+ const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
475
+ route.skill = 'screen add';
476
+ if (!opts.api)
477
+ return route;
478
+ const ep = findEndpoint(map, opts.api);
479
+ const c = map.conventions;
480
+ const page = route.changes.find((ch) => ch.before === null && /\/page\.(t|j)sx$/.test(ch.path));
481
+ if (!page?.after) {
482
+ route.warnings.push('screen add fills App Router pages only; the Pages Router page was created without the data call.');
483
+ return route;
484
+ }
485
+ const text = readText(join(root, ep.file)) ?? '';
486
+ const decl = findDeclaration(text, ep.name, 'ts');
487
+ if (!decl)
488
+ throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
489
+ const args = tsArgs(decl.params);
490
+ const params = routeParams(opts.path);
491
+ const missing = args.filter((a) => a.required && !params.includes(a.name));
492
+ if (missing.length)
493
+ throw missingArgsError(ep, missing, params, opts.name);
494
+ const callArgs = args
495
+ .filter((a) => params.includes(a.name) || a.required)
496
+ .map((a) => (/^number$/.test(a.type) ? `Number(${a.name})` : a.name))
497
+ .join(', ');
498
+ const comp = /export default (?:async )?function (\w+)/.exec(page.after)?.[1] ?? `${pascal(opts.name)}Page`;
499
+ const title = /<h1>([^<{]*)/.exec(page.after)?.[1]?.trim() ?? pascal(opts.name);
500
+ const shape = responseShape(decl.returnType, map);
501
+ const tf = titleField(shape.model);
502
+ const idf = idField(shape.model);
503
+ const dataVar = shape.list ? 'items' : camel(shape.model?.name ?? 'data');
504
+ // Import the endpoint from the barrel when the API folder has one that re-exports it.
505
+ const dir = posix.dirname(ep.file);
506
+ const barrel = [`${dir}/index.ts`, `${dir}/index.js`].find((f) => exists(join(root, f)) && /export\s+\*\s+from|export\s*\{/.test(readText(join(root, f)) ?? ''));
507
+ const from = importPath(page.path, barrel ?? ep.file, c);
508
+ const q = c.quote === 'double' ? '"' : "'";
509
+ const semi = c.semicolons === 'false' ? '' : ';';
510
+ const render = shape.list
511
+ ? [
512
+ ` {${dataVar}.length === 0 ? (`,
513
+ ' <p>Nothing here yet.</p>',
514
+ ' ) : (',
515
+ ' <ul>',
516
+ ` {${dataVar}.map((item${idf ? '' : ', index'}) => (`,
517
+ ` <li key={${idf ? `item.${idf}` : 'index'}}>{${tf ? `String(item.${tf})` : 'JSON.stringify(item)'}}</li>`,
518
+ ' ))}',
519
+ ' </ul>',
520
+ ' )}',
521
+ ]
522
+ : shape.model?.fields.length
523
+ ? [' <dl>', ...shape.model.fields.flatMap((f) => [` <dt>${f.name}</dt>`, ` <dd>{String(${dataVar}.${f.name} ?? '')}</dd>`]), ' </dl>']
524
+ : [` <pre>{JSON.stringify(${dataVar}, null, 2)}</pre>`];
525
+ const sigParams = params.length ? `{ params }: { params: Promise<{ ${params.map((p) => `${p}: string`).join('; ')} }> }` : '';
526
+ page.after = [
527
+ `import { ${ep.name} } from ${q}${from}${q}${semi}`,
528
+ '',
529
+ // Without route parameters Next.js would render the page once at build time,
530
+ // calling the API then (and failing the build when it is not reachable).
531
+ ...(params.length ? [] : ['// Rendered on each request: the data comes from the API.', `export const dynamic = ${q}force-dynamic${q}${semi}`, '']),
532
+ `export default async function ${comp}(${sigParams}) {`,
533
+ ...(params.length ? [` const { ${params.join(', ')} } = await params${semi}`] : []),
534
+ ` const ${dataVar} = await ${ep.name}(${callArgs})${semi}`,
535
+ ' return (',
536
+ ' <main>',
537
+ ` <h1>${title}</h1>`,
538
+ ...render,
539
+ ' </main>',
540
+ ` )${semi}`,
541
+ '}',
542
+ '',
543
+ ].join('\n');
544
+ // Next 14 passes params directly, not as a promise.
545
+ const pkg = readText(join(root, 'package.json')) ?? '';
546
+ const nextMajor = Number(/"next"\s*:\s*"[^\d]*(\d+)/.exec(pkg)?.[1] ?? 15);
547
+ if (nextMajor < 15 && params.length)
548
+ page.after = page.after.replace(`params: Promise<{`, 'params: {').replace(/ }> }\) \{/, ' } }) {').replace(`= await params${semi}`, `= params${semi}`);
549
+ const folder = posix.dirname(page.path);
550
+ const ext = page.path.endsWith('.tsx') ? 'tsx' : 'jsx';
551
+ const extra = new Map([
552
+ [`${folder}/loading.${ext}`, `export default function Loading() {\n return <p>Loading…</p>${semi}\n}\n`],
553
+ [
554
+ `${folder}/error.${ext}`,
555
+ [
556
+ `${q}use client${q}${semi}`,
557
+ '',
558
+ ext === 'tsx' ? 'export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {' : 'export default function Error({ error, reset }) {',
559
+ ' return (',
560
+ ' <main>',
561
+ ` <p>Could not load this page: {error.message}</p>`,
562
+ ' <button onClick={() => reset()}>Retry</button>',
563
+ ' </main>',
564
+ ` )${semi}`,
565
+ '}',
566
+ '',
567
+ ].join('\n'),
568
+ ],
569
+ ]);
570
+ const files = new Map(route.changes.filter((ch) => ch.after !== null).map((ch) => [ch.path, ch.after]));
571
+ for (const [f, t] of extra)
572
+ if (!exists(join(root, f)))
573
+ files.set(f, t);
574
+ const summary = [...route.summary, `${page.path} awaits ${ep.name}() on the server and renders the result`, `Create ${folder}/loading.${ext} and ${folder}/error.${ext} (loading state, and an error with retry)`];
575
+ return buildChangeSet(root, 'screen add', { files, summary, warnings: route.warnings, generated: [] });
576
+ }
577
+ export async function planScreenAdd(root, adapter, opts) {
578
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
579
+ const c = map.conventions;
580
+ if (adapter.id === 'android')
581
+ return (await import("./androidUi.js")).planAndroidScreen(root, map, opts);
582
+ if (adapter.id === 'react-native')
583
+ return (await import("./reactNativeUi.js")).planRnScreen(root, map, opts);
584
+ if (adapter.id === 'react')
585
+ return (await import("./reactUi.js")).planReactScreen(root, map, opts);
586
+ if (adapter.id === 'ios')
587
+ return (await import("./iosUi.js")).planIosScreen(root, map, opts);
588
+ if (opts.form)
589
+ return (await import("./screenForm.js")).planFormScreen(root, adapter, map, opts);
590
+ if (adapter.id === 'flutter' && (c.router === 'getx' || c.di === 'getx' || c.stateManagement === 'getx'))
591
+ return getxScreen(root, adapter, map, opts);
592
+ if (adapter.id === 'flutter')
593
+ return flutterScreen(root, adapter, map, opts);
594
+ if (adapter.id === 'nextjs')
595
+ return nextScreen(root, adapter, map, opts);
596
+ throw new ToolError('not_supported', `screen add needs the Next.js, Flutter, React Native or Android adapter; this project uses the ${adapter.id} adapter.`);
597
+ }