codz-cli 0.1.2 → 0.1.8

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.
@@ -19,6 +19,8 @@ import { exists, readText } from "../core/fsutil.js";
19
19
  import { ToolError } from "../core/types.js";
20
20
  import { camel, kebab, pascal, snake } from "../input/naming.js";
21
21
  import { findDeclaration } from "./apiEdit.js";
22
+ import { splitTsParams } from "../adapters/reactQuery.js";
23
+ import { dartPagingCall, dartSearchMatch, detectPaging, PAGE_SIZE, pagingSummary, pagingWarnings, tsPagingArgs, tsPagingParams } from "./paging.js";
22
24
  import { planModuleAdd } from "./module.js";
23
25
  import { planRouteAdd } from "./route.js";
24
26
  /** 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). */
@@ -101,6 +103,14 @@ export function routeParams(path) {
101
103
  function missingArgsError(ep, missing, params, name) {
102
104
  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
105
  }
106
+ /** The item type of a Dart list type: List<ProductModel> → ProductModel. */
107
+ const itemType = (t) => /^List<(.+)>\??$/.exec(t.trim())?.[1] ?? 'dynamic';
108
+ /** Paging for a Dart list endpoint (Flutter screens). */
109
+ function dartPaging(shape, args, opts, name, returnType) {
110
+ if (!shape.list)
111
+ throw new ToolError('screen_not_list', `${name}() returns ${returnType}, not a list; --paginate and --search are for list screens.`);
112
+ return detectPaging(args.map((a) => ({ name: a.name, type: a.type })), { paginate: opts.paginate, search: opts.search, modelStringFields: (shape.model?.fields ?? []).filter((f) => f.type === 'String').map((f) => f.name), titleField: titleField(shape.model) });
113
+ }
104
114
  // ---------------- GetX ----------------
105
115
  async function getxScreen(root, adapter, map, opts) {
106
116
  const ep = opts.api ? findEndpoint(map, opts.api) : undefined;
@@ -112,6 +122,8 @@ async function getxScreen(root, adapter, map, opts) {
112
122
  const ctrlChange = cs.changes.find((c) => c.path.endsWith(`/controllers/${snake(opts.name)}_controller.dart`));
113
123
  if (!viewChange?.after || !ctrlChange?.after)
114
124
  return cs;
125
+ if (opts.paginate || opts.search)
126
+ return getxPaged(root, map, opts, ep, cs, viewChange, ctrlChange);
115
127
  // The names api wire chose: getProducts → products / isProductsLoading / productsError / loadProducts().
116
128
  const stripped = ep.name.replace(/^(get|fetch|load)(?=[A-Z])/, '');
117
129
  const base = stripped !== ep.name ? camel(stripped) : ep.name;
@@ -188,6 +200,154 @@ async function getxScreen(root, adapter, map, opts) {
188
200
  }
189
201
  return cs;
190
202
  }
203
+ /** GetX: the module's controller holds items, isLoading, error, query (and hasMore), and its view lists them. */
204
+ function getxPaged(root, map, opts, ep, cs, view, ctrl) {
205
+ const sig = readMethod(readText(join(root, ep.file)) ?? '', ep.name);
206
+ if (!sig)
207
+ throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
208
+ const args = dartArgs(sig.params);
209
+ const params = routeParams(opts.path);
210
+ const missing = args.filter((a) => a.required && !params.includes(a.name));
211
+ if (missing.length)
212
+ throw missingArgsError(ep, missing, params, opts.name);
213
+ const shape = responseShape(sig.returnType, map);
214
+ const paging = dartPaging(shape, args, opts, ep.name, sig.returnType);
215
+ const item = itemType(sig.returnType);
216
+ const repoLine = /^ {2}final (\w+) (_\w+) = Get\.find<\w+>\(\);$/m.exec(ctrl.after);
217
+ const cls = /class\s+(\w+)\s+extends\s+GetxController/.exec(ctrl.after)?.[1];
218
+ if (!repoLine || !cls)
219
+ throw new ToolError('not_supported', `Could not read the controller codz wrote (${ctrl.path}).`);
220
+ const repo = repoLine[2];
221
+ const searchable = Boolean(paging.search || paging.clientSearch);
222
+ const pg = paging.paginate;
223
+ const first = paging.page?.mode === 'offset' ? '0' : '1';
224
+ const conv = (a, v) => (/^int\??$/.test(a.type) ? `int.parse(${v})` : /^double\??$/.test(a.type) ? `double.parse(${v})` : v);
225
+ const routeVals = new Map(args.filter((a) => params.includes(a.name)).map((a) => [a.name, conv(a, `Get.parameters['${a.name}']!`)]));
226
+ const call = dartPagingCall(args, paging, routeVals, { page: pg ? 'next' : undefined, search: paging.search ? 'query.value.isEmpty ? null : query.value' : undefined }, '_pageSize');
227
+ const strFields = paging.searchFields.map((f) => ({ name: f, nullable: fieldOf(shape.model, f)?.nullable ?? true }));
228
+ const imports = ctrl.after.split('\n').filter((l) => l.startsWith('import ') && !l.includes('package:get/get.dart'));
229
+ ctrl.after = [
230
+ ...(paging.search ? ["import 'dart:async';", ''] : []),
231
+ "import 'package:get/get.dart';",
232
+ '',
233
+ ...imports,
234
+ '',
235
+ `class ${cls} extends GetxController {`,
236
+ repoLine[0],
237
+ '',
238
+ ...(pg && paging.size ? [` static const _pageSize = ${PAGE_SIZE};`, ''] : []),
239
+ ` // GET ${ep.path} via ${repoLine[1]}.${ep.name}`,
240
+ ` final items = <${item}>[].obs;`,
241
+ ' final isLoading = false.obs;',
242
+ ' final error = RxnString();',
243
+ ...(pg ? [' final hasMore = true.obs;'] : []),
244
+ ...(searchable ? [" final query = ''.obs;"] : []),
245
+ ...(pg && paging.page.mode === 'page' ? [' int _page = 0;'] : []),
246
+ ' int _request = 0;',
247
+ ...(paging.search ? [' Timer? _debounce;'] : []),
248
+ '',
249
+ ...(paging.clientSearch
250
+ ? [' /// The items that match the search.', ` List<${item}> get visible => query.value.isEmpty ? items : items.where((item) => ${dartSearchMatch(strFields, 'item', 'query.value', camel)}).toList();`, '']
251
+ : []),
252
+ ` /// Loads ${pg ? 'the first page again (reset) or the next one' : 'the list'}. Only the latest request counts.`,
253
+ ` Future<void> load(${pg ? '{bool reset = false}' : ''}) async {`,
254
+ ...(pg ? [' if (!reset && (isLoading.value || !hasMore.value)) return;'] : []),
255
+ ' final request = ++_request;',
256
+ ' isLoading.value = true;',
257
+ ' error.value = null;',
258
+ ' try {',
259
+ ...(pg ? [` final next = reset ? ${first} : ${paging.page.mode === 'offset' ? 'items.length' : '_page + 1'};`] : []),
260
+ ` final rows = await ${repo}.${ep.name}(${call});`,
261
+ ' if (request != _request) return;',
262
+ ...(pg ? [' if (reset) {', ' items.assignAll(rows);', ' } else {', ' items.addAll(rows);', ' }', ...(paging.page.mode === 'page' ? [' _page = next;'] : []), ` hasMore.value = ${paging.size ? 'rows.length == _pageSize' : 'rows.isNotEmpty'};`] : [' items.assignAll(rows);']),
263
+ ' } catch (e) {',
264
+ ' if (request == _request) error.value = e.toString();',
265
+ ' } finally {',
266
+ ' if (request == _request) isLoading.value = false;',
267
+ ' }',
268
+ ' }',
269
+ ...(paging.search
270
+ ? ['', ' void search(String text) {', ' _debounce?.cancel();', ' _debounce = Timer(const Duration(milliseconds: 400), () {', ' query.value = text.trim();', ` load(${pg ? 'reset: true' : ''});`, ' });', ' }']
271
+ : paging.clientSearch
272
+ ? ['', ' void search(String text) => query.value = text.trim().toLowerCase();']
273
+ : []),
274
+ '',
275
+ ' @override',
276
+ ' void onInit() {',
277
+ ' super.onInit();',
278
+ ` load(${pg ? 'reset: true' : ''});`,
279
+ ' }',
280
+ ...(paging.search ? ['', ' @override', ' void onClose() {', ' _debounce?.cancel();', ' super.onClose();', ' }'] : []),
281
+ '}',
282
+ '',
283
+ ].join('\n');
284
+ const title = titleField(shape.model);
285
+ const show = (v) => (title ? dartShow(`${v}.${camel(title)}`, fieldOf(shape.model, title)) : `${v}.toString()`);
286
+ const reset = pg ? 'reset: true' : '';
287
+ const I = ' ';
288
+ const list = [
289
+ 'Obx(() {',
290
+ `${I}final shown = controller.${paging.clientSearch ? 'visible' : 'items'};`,
291
+ `${I}final error = controller.error.value;`,
292
+ `${I}if (controller.items.isEmpty && controller.isLoading.value) return const Center(child: CircularProgressIndicator());`,
293
+ `${I}if (controller.items.isEmpty && error != null) {`,
294
+ `${I} return Center(`,
295
+ `${I} child: Column(`,
296
+ `${I} mainAxisSize: MainAxisSize.min,`,
297
+ `${I} children: [`,
298
+ `${I} Text(error, textAlign: TextAlign.center),`,
299
+ `${I} TextButton(onPressed: () => controller.load(${reset}), child: const Text('Retry')),`,
300
+ `${I} ],`,
301
+ `${I} ),`,
302
+ `${I} );`,
303
+ `${I}}`,
304
+ `${I}if (shown.isEmpty${pg ? ' && !controller.hasMore.value' : ''}) return Center(child: Text(${searchable ? "controller.query.value.isEmpty ? 'No items' : 'No matches'" : "'No items'"}));`,
305
+ `${I}return RefreshIndicator(`,
306
+ `${I} onRefresh: () => controller.load(${reset}),`,
307
+ `${I} child: ListView.builder(`,
308
+ `${I} physics: const AlwaysScrollableScrollPhysics(),`,
309
+ `${I} itemCount: shown.length${pg ? ' + (controller.hasMore.value || error != null ? 1 : 0)' : ''},`,
310
+ `${I} itemBuilder: (context, index) {`,
311
+ ...(pg
312
+ ? [
313
+ `${I} if (index == shown.length) {`,
314
+ `${I} if (error != null) return TextButton(onPressed: () => controller.load(), child: const Text('Retry'));`,
315
+ `${I} // The end of the list is on screen: ask for the next page.`,
316
+ `${I} if (!controller.isLoading.value) WidgetsBinding.instance.addPostFrameCallback((_) => controller.load());`,
317
+ `${I} return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));`,
318
+ `${I} }`,
319
+ ]
320
+ : []),
321
+ `${I} return ListTile(title: Text(${show('shown[index]')}));`,
322
+ `${I} },`,
323
+ `${I} ),`,
324
+ `${I});`,
325
+ ' })',
326
+ ];
327
+ const body = searchable
328
+ ? [
329
+ ' body: Column(',
330
+ ' children: [',
331
+ ' Padding(',
332
+ ' padding: const EdgeInsets.all(12),',
333
+ " child: TextField(onChanged: controller.search, decoration: const InputDecoration(hintText: 'Search', prefixIcon: Icon(Icons.search), border: OutlineInputBorder())),",
334
+ ' ),',
335
+ ` Expanded(child: ${list.map((l, i) => (i === 0 ? l : ` ${l}`)).join('\n')}),`,
336
+ ' ],',
337
+ ' ),',
338
+ ].join('\n')
339
+ : ` body: ${list.join('\n')},`;
340
+ const replaced = view.after.replace(/ {6}body: const Center\(child: Text\('\w+ is working'\)\),/, body);
341
+ if (replaced === view.after)
342
+ throw new ToolError('not_supported', `Could not fill the view codz wrote (${view.path}).`);
343
+ view.after = replaced;
344
+ cs.summary = cs.summary.filter((l) => !/^In \w+View: Obx|^Update .*controller\.dart: load|^Show /.test(l));
345
+ cs.summary.push(`${ctrl.path}: items, isLoading, error${searchable ? ', query' : ''}${pg ? ', hasMore' : ''} and load()`, `${view.path} lists them and ${pagingSummary(paging)}`);
346
+ cs.warnings.push(...pagingWarnings(paging));
347
+ if (params.length)
348
+ cs.warnings.push(`The controller reads ${params.join(', ')} from Get.parameters; open the page with Get.toNamed(Routes.X, parameters: {...}).`);
349
+ return cs;
350
+ }
191
351
  // ---------------- Flutter (riverpod, get_it) ----------------
192
352
  async function flutterScreen(root, adapter, map, opts) {
193
353
  const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
@@ -233,6 +393,146 @@ async function flutterScreen(root, adapter, map, opts) {
233
393
  const stateBase = riverpod ? 'ConsumerStatefulWidget' : 'StatefulWidget';
234
394
  const stateCls = riverpod ? `ConsumerState<${cls}>` : `State<${cls}>`;
235
395
  const titleText = page.after.match(/AppBar\(title: const Text\('([^']*)'\)\)/)?.[1] ?? pascal(opts.name);
396
+ if (opts.paginate || opts.search) {
397
+ const paging = dartPaging(shape, args, opts, ep.name, sig.returnType);
398
+ const routeVals = new Map(args.filter((a) => params.includes(a.name)).map((a) => [a.name, /^int\??$/.test(a.type) ? `int.parse(widget.${a.name})` : /^double\??$/.test(a.type) ? `double.parse(widget.${a.name})` : `widget.${a.name}`]));
399
+ const item = itemType(sig.returnType);
400
+ const searchable = Boolean(paging.search || paging.clientSearch);
401
+ const first = paging.page?.mode === 'offset' ? '0' : '1';
402
+ const call = dartPagingCall(args, paging, routeVals, { page: paging.paginate ? 'next' : undefined, search: paging.search ? '_query.isEmpty ? null : _query' : undefined }, '_pageSize');
403
+ const show = (v) => (title ? dartShow(`${v}.${camel(title)}`, fieldOf(shape.model, title)) : `${v}.toString()`);
404
+ const strFields = paging.searchFields.map((n) => ({ name: n, nullable: fieldOf(shape.model, n)?.nullable ?? true }));
405
+ let text = [
406
+ ...(paging.search ? ["import 'dart:async';", ''] : []),
407
+ "import 'package:flutter/material.dart';",
408
+ '',
409
+ `class ${cls} extends ${stateBase} {`,
410
+ ` ${ctor}`,
411
+ ...(fields ? ['', fields.trimEnd()] : []),
412
+ '',
413
+ ' @override',
414
+ ` ${stateCls.replace(/<.*/, '')}<${cls}> createState() => _${cls}State();`,
415
+ '}',
416
+ '',
417
+ `class _${cls}State extends ${stateCls} {`,
418
+ ...(paging.paginate && paging.size ? [` static const _pageSize = ${PAGE_SIZE};`, ''] : []),
419
+ ` final _items = <${item}>[];`,
420
+ ...(paging.search ? [' Timer? _debounce;'] : []),
421
+ ...(searchable ? [" String _query = '';"] : []),
422
+ ...(paging.paginate && paging.page.mode === 'page' ? [' int _page = 0;'] : []),
423
+ ...(paging.paginate ? [' bool _hasMore = true;'] : []),
424
+ ' bool _loading = false;',
425
+ ' Object? _error;',
426
+ ' int _request = 0;',
427
+ '',
428
+ ' @override',
429
+ ' void initState() {',
430
+ ' super.initState();',
431
+ ` _load(${paging.paginate ? 'reset: true' : ''});`,
432
+ ' }',
433
+ ...(paging.search ? ['', ' @override', ' void dispose() {', ' _debounce?.cancel();', ' super.dispose();', ' }'] : []),
434
+ '',
435
+ ` /// Loads ${paging.paginate ? 'the first page again (reset) or the next one' : 'the list'}.`,
436
+ ` Future<void> _load(${paging.paginate ? '{bool reset = false}' : ''}) async {`,
437
+ ...(paging.paginate ? [' if (!reset && (_loading || !_hasMore)) return;'] : []),
438
+ ' // Only the latest request counts: a new search or refresh replaces one still loading.',
439
+ ' final request = ++_request;',
440
+ ' setState(() {',
441
+ ' _loading = true;',
442
+ ' _error = null;',
443
+ ' });',
444
+ ' try {',
445
+ ...(paging.paginate ? [` final next = reset ? ${first} : ${paging.page.mode === 'offset' ? '_items.length' : '_page + 1'};`] : []),
446
+ ` final rows = await ${repoExpr}.${ep.name}(${call});`,
447
+ ' if (!mounted || request != _request) return;',
448
+ ' setState(() {',
449
+ ...(paging.paginate ? [' if (reset) _items.clear();'] : [' _items.clear();']),
450
+ ' _items.addAll(rows);',
451
+ ...(paging.paginate ? [...(paging.page.mode === 'page' ? [' _page = next;'] : []), ` _hasMore = ${paging.size ? 'rows.length == _pageSize' : 'rows.isNotEmpty'};`] : []),
452
+ ' });',
453
+ ' } catch (e) {',
454
+ ' if (mounted && request == _request) setState(() => _error = e);',
455
+ ' } finally {',
456
+ ' if (mounted && request == _request) setState(() => _loading = false);',
457
+ ' }',
458
+ ' }',
459
+ ...(paging.search
460
+ ? ['', ' void _onSearch(String text) {', ' _debounce?.cancel();', ' _debounce = Timer(const Duration(milliseconds: 400), () {', ' _query = text.trim();', ` _load(${paging.paginate ? 'reset: true' : ''});`, ' });', ' }']
461
+ : paging.clientSearch
462
+ ? ['', ' void _onSearch(String text) => setState(() => _query = text.trim().toLowerCase());']
463
+ : []),
464
+ '',
465
+ ' @override',
466
+ ' Widget build(BuildContext context) {',
467
+ ' return Scaffold(',
468
+ ` appBar: AppBar(title: const Text('${titleText}')),`,
469
+ ...(searchable
470
+ ? [
471
+ ' body: Column(',
472
+ ' children: [',
473
+ ' Padding(',
474
+ ' padding: const EdgeInsets.all(12),',
475
+ " child: TextField(onChanged: _onSearch, decoration: const InputDecoration(hintText: 'Search', prefixIcon: Icon(Icons.search), border: OutlineInputBorder())),",
476
+ ' ),',
477
+ ' Expanded(child: _list()),',
478
+ ' ],',
479
+ ' ),',
480
+ ]
481
+ : [' body: _list(),']),
482
+ ' );',
483
+ ' }',
484
+ '',
485
+ ' Widget _list() {',
486
+ ` final shown = ${paging.clientSearch ? `_query.isEmpty ? _items : _items.where((item) => ${dartSearchMatch(strFields, 'item', '_query', camel)}).toList()` : '_items'};`,
487
+ ' if (_items.isEmpty && _loading) return const Center(child: CircularProgressIndicator());',
488
+ ' if (_items.isEmpty && _error != null) {',
489
+ ' return Center(',
490
+ ' child: Column(',
491
+ ' mainAxisSize: MainAxisSize.min,',
492
+ ' children: [',
493
+ " Text('$_error', textAlign: TextAlign.center),",
494
+ ` TextButton(onPressed: () => _load(${paging.paginate ? 'reset: true' : ''}), child: const Text('Retry')),`,
495
+ ' ],',
496
+ ' ),',
497
+ ' );',
498
+ ' }',
499
+ ` if (shown.isEmpty${paging.paginate ? ' && !_hasMore' : ''}) return Center(child: Text(${searchable ? "_query.isEmpty ? 'No items' : 'No matches'" : "'No items'"}));`,
500
+ ' return RefreshIndicator(',
501
+ ` onRefresh: () => _load(${paging.paginate ? 'reset: true' : ''}),`,
502
+ ' child: ListView.builder(',
503
+ ' physics: const AlwaysScrollableScrollPhysics(),',
504
+ ` itemCount: shown.length${paging.paginate ? ' + (_hasMore || _error != null ? 1 : 0)' : ''},`,
505
+ ' itemBuilder: (context, index) {',
506
+ ...(paging.paginate
507
+ ? [
508
+ ' if (index == shown.length) {',
509
+ " if (_error != null) return TextButton(onPressed: () => _load(), child: const Text('Retry'));",
510
+ ' // The end of the list is on screen: ask for the next page.',
511
+ ' if (!_loading) WidgetsBinding.instance.addPostFrameCallback((_) => _load());',
512
+ ' return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));',
513
+ ' }',
514
+ ]
515
+ : []),
516
+ ` return ListTile(title: Text(${show('shown[index]')}));`,
517
+ ' },',
518
+ ' ),',
519
+ ' );',
520
+ ' }',
521
+ '}',
522
+ '',
523
+ ].join('\n');
524
+ if (riverpod)
525
+ text = addDartImport(text, 'package:flutter_riverpod/flutter_riverpod.dart');
526
+ text = addDartImport(text, importUri(page.path, ep.file, c));
527
+ if (getIt && c.diFile)
528
+ text = addDartImport(text, importUri(page.path, c.diFile, c));
529
+ if (shape.model && shape.model.file !== ep.file)
530
+ text = addDartImport(text, importUri(page.path, shape.model.file, c));
531
+ page.after = text;
532
+ route.summary.push(`${page.path} lists ${ep.name}() and ${pagingSummary(paging)}`);
533
+ route.warnings.push(...pagingWarnings(paging));
534
+ return route;
535
+ }
236
536
  const I = ' ';
237
537
  const dataView = shape.list
238
538
  ? [
@@ -344,6 +644,8 @@ function blocScreen(root, route, page, cls, ep, sig, args, params, map, opts) {
344
644
  const repoExpr = getIt ? `${c.diInstance || 'getIt'}<${sig.repoClass}>()` : `context.read<${sig.repoClass}>()`;
345
645
  if (!getIt)
346
646
  route.warnings.push(`${page.path} reads ${sig.repoClass} with context.read, so provide it above this page: RepositoryProvider(create: (_) => ${sig.repoClass}(...), child: ...).`);
647
+ if (opts.paginate || opts.search)
648
+ return blocPagedScreen(root, route, page, cls, ep, sig, args, params, map, opts, { cubit, state, cubitFile, repoExpr, base });
347
649
  let cubitText = [
348
650
  "import 'package:flutter_bloc/flutter_bloc.dart';",
349
651
  '',
@@ -469,6 +771,171 @@ function blocScreen(root, route, page, cls, ep, sig, args, params, map, opts) {
469
771
  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
772
  return buildChangeSet(root, 'screen add', { files, summary, warnings: route.warnings, generated: [] });
471
773
  }
774
+ /** Bloc: a Cubit with one state (items, loading, error, query, hasMore) for a paged or searchable list, and its page. */
775
+ function blocPagedScreen(root, route, page, cls, ep, sig, args, params, map, opts, n) {
776
+ const c = map.conventions;
777
+ const T = sig.returnType;
778
+ const shape = responseShape(T, map);
779
+ const paging = dartPaging(shape, args, opts, ep.name, T);
780
+ const item = itemType(T);
781
+ const { cubit, state } = n;
782
+ const searchable = Boolean(paging.search || paging.clientSearch);
783
+ const pg = paging.paginate;
784
+ const first = paging.page?.mode === 'offset' ? '0' : '1';
785
+ const conv = (a, v) => (/^int\??$/.test(a.type) ? `int.parse(${v})` : /^double\??$/.test(a.type) ? `double.parse(${v})` : v);
786
+ const routeVals = new Map(args.filter((a) => params.includes(a.name)).map((a) => [a.name, conv(a, a.name)]));
787
+ const call = dartPagingCall(args, paging, routeVals, { page: pg ? 'next' : undefined, search: paging.search ? 'state.query.isEmpty ? null : state.query' : undefined }, '_pageSize');
788
+ const title = titleField(shape.model);
789
+ const show = (v) => (title ? dartShow(`${v}.${camel(title)}`, fieldOf(shape.model, title)) : `${v}.toString()`);
790
+ const strFields = paging.searchFields.map((f) => ({ name: f, nullable: fieldOf(shape.model, f)?.nullable ?? true }));
791
+ const stateFields = [
792
+ { decl: `final List<${item}> items;`, param: 'this.items = const []', copy: `List<${item}>? items`, set: 'items: items ?? this.items' },
793
+ { decl: 'final bool loading;', param: 'this.loading = false', copy: 'bool? loading', set: 'loading: loading ?? this.loading' },
794
+ ...(pg ? [{ decl: 'final bool hasMore;', param: 'this.hasMore = true', copy: 'bool? hasMore', set: 'hasMore: hasMore ?? this.hasMore' }] : []),
795
+ { decl: 'final String? error;', param: 'this.error', copy: 'String? error, bool clearError = false', set: 'error: clearError ? null : (error ?? this.error)' },
796
+ ...(searchable ? [{ decl: 'final String query;', param: "this.query = ''", copy: 'String? query', set: 'query: query ?? this.query' }] : []),
797
+ ];
798
+ let cubitText = [
799
+ ...(paging.search ? ["import 'dart:async';", ''] : []),
800
+ "import 'package:flutter_bloc/flutter_bloc.dart';",
801
+ '',
802
+ `class ${state} {`,
803
+ ` const ${state}({${stateFields.map((f) => f.param).join(', ')}});`,
804
+ '',
805
+ ...stateFields.map((f) => ` ${f.decl}`),
806
+ '',
807
+ ` ${state} copyWith({${stateFields.map((f) => f.copy).join(', ')}}) => ${state}(`,
808
+ ...stateFields.map((f) => ` ${f.set},`),
809
+ ' );',
810
+ '}',
811
+ '',
812
+ `class ${cubit} extends Cubit<${state}> {`,
813
+ ` ${cubit}(this._api${params.length ? `, {${params.map((p) => `required this.${p}`).join(', ')}}` : ''}) : super(const ${state}());`,
814
+ '',
815
+ ...(pg && paging.size ? [` static const _pageSize = ${PAGE_SIZE};`, ''] : []),
816
+ ` final ${sig.repoClass} _api;`,
817
+ ...params.map((p) => ` final String ${p};`),
818
+ ...(pg && paging.page.mode === 'page' ? [' int _page = 0;'] : []),
819
+ ' int _request = 0;',
820
+ ...(paging.search ? [' Timer? _debounce;'] : []),
821
+ '',
822
+ ` /// Loads ${pg ? 'the first page again (reset) or the next one' : 'the list'}. Only the latest request counts.`,
823
+ ` Future<void> load(${pg ? '{bool reset = false}' : ''}) async {`,
824
+ ...(pg ? [' if (!reset && (state.loading || !state.hasMore)) return;'] : []),
825
+ ' final request = ++_request;',
826
+ ' emit(state.copyWith(loading: true, clearError: true));',
827
+ ' try {',
828
+ ...(pg ? [` final next = reset ? ${first} : ${paging.page.mode === 'offset' ? 'state.items.length' : '_page + 1'};`] : []),
829
+ ` final rows = await _api.${ep.name}(${call});`,
830
+ ' if (request != _request || isClosed) return;',
831
+ ...(pg && paging.page.mode === 'page' ? [' _page = next;'] : []),
832
+ pg
833
+ ? ` emit(state.copyWith(items: reset ? rows : [...state.items, ...rows], hasMore: ${paging.size ? 'rows.length == _pageSize' : 'rows.isNotEmpty'}, loading: false));`
834
+ : ' emit(state.copyWith(items: rows, loading: false));',
835
+ ' } catch (e) {',
836
+ ' if (request == _request && !isClosed) emit(state.copyWith(loading: false, error: e.toString()));',
837
+ ' }',
838
+ ' }',
839
+ ...(paging.search
840
+ ? ['', ' void search(String text) {', ' _debounce?.cancel();', ' _debounce = Timer(const Duration(milliseconds: 400), () {', ' emit(state.copyWith(query: text.trim()));', ` load(${pg ? 'reset: true' : ''});`, ' });', ' }', '', ' @override', ' Future<void> close() {', ' _debounce?.cancel();', ' return super.close();', ' }']
841
+ : paging.clientSearch
842
+ ? ['', ' void search(String text) => emit(state.copyWith(query: text.trim().toLowerCase()));']
843
+ : []),
844
+ '}',
845
+ '',
846
+ ].join('\n');
847
+ cubitText = addDartImport(cubitText, importUri(n.cubitFile, ep.file, c));
848
+ if (shape.model && shape.model.file !== ep.file)
849
+ cubitText = addDartImport(cubitText, importUri(n.cubitFile, shape.model.file, c));
850
+ const reset = pg ? 'reset: true' : '';
851
+ const titleText = page.after.match(/AppBar\(title: const Text\('([^']*)'\)\)/)?.[1] ?? n.base;
852
+ const ctor = params.length ? `const ${cls}({super.key, ${params.map((p) => `required this.${p}`).join(', ')}});` : `const ${cls}({super.key});`;
853
+ const getIt = c.di === 'get_it';
854
+ let pageText = [
855
+ "import 'package:flutter/material.dart';",
856
+ "import 'package:flutter_bloc/flutter_bloc.dart';",
857
+ '',
858
+ `class ${cls} extends StatelessWidget {`,
859
+ ` ${ctor}`,
860
+ ...(params.length ? ['', ...params.map((p) => ` final String ${p};`)] : []),
861
+ '',
862
+ ' @override',
863
+ ' Widget build(BuildContext context) {',
864
+ ' return BlocProvider(',
865
+ ` create: (context) => ${cubit}(${n.repoExpr}${params.length ? `, ${params.map((p) => `${p}: ${p}`).join(', ')}` : ''})..load(${reset}),`,
866
+ ' child: Scaffold(',
867
+ ` appBar: AppBar(title: const Text('${titleText}')),`,
868
+ ` body: BlocBuilder<${cubit}, ${state}>(`,
869
+ ...(searchable
870
+ ? [
871
+ ' builder: (context, state) => Column(',
872
+ ' children: [',
873
+ ' Padding(',
874
+ ' padding: const EdgeInsets.all(12),',
875
+ ` child: TextField(onChanged: context.read<${cubit}>().search, decoration: const InputDecoration(hintText: 'Search', prefixIcon: Icon(Icons.search), border: OutlineInputBorder())),`,
876
+ ' ),',
877
+ ' Expanded(child: _list(context, state)),',
878
+ ' ],',
879
+ ' ),',
880
+ ]
881
+ : [' builder: _list,']),
882
+ ' ),',
883
+ ' ),',
884
+ ' );',
885
+ ' }',
886
+ '',
887
+ ` Widget _list(BuildContext context, ${state} state) {`,
888
+ ` final shown = ${paging.clientSearch ? `state.query.isEmpty ? state.items : state.items.where((item) => ${dartSearchMatch(strFields, 'item', 'state.query', camel)}).toList()` : 'state.items'};`,
889
+ ' final error = state.error;',
890
+ ' if (state.items.isEmpty && state.loading) return const Center(child: CircularProgressIndicator());',
891
+ ' if (state.items.isEmpty && error != null) {',
892
+ ' return Center(',
893
+ ' child: Column(',
894
+ ' mainAxisSize: MainAxisSize.min,',
895
+ ' children: [',
896
+ ' Text(error, textAlign: TextAlign.center),',
897
+ ` TextButton(onPressed: () => context.read<${cubit}>().load(${reset}), child: const Text('Retry')),`,
898
+ ' ],',
899
+ ' ),',
900
+ ' );',
901
+ ' }',
902
+ ` if (shown.isEmpty${pg ? ' && !state.hasMore' : ''}) return Center(child: Text(${searchable ? "state.query.isEmpty ? 'No items' : 'No matches'" : "'No items'"}));`,
903
+ ' return RefreshIndicator(',
904
+ ` onRefresh: () => context.read<${cubit}>().load(${reset}),`,
905
+ ' child: ListView.builder(',
906
+ ' physics: const AlwaysScrollableScrollPhysics(),',
907
+ ` itemCount: shown.length${pg ? ' + (state.hasMore || error != null ? 1 : 0)' : ''},`,
908
+ ' itemBuilder: (context, index) {',
909
+ ...(pg
910
+ ? [
911
+ ' if (index == shown.length) {',
912
+ ` if (error != null) return TextButton(onPressed: () => context.read<${cubit}>().load(), child: const Text('Retry'));`,
913
+ ' // The end of the list is on screen: ask for the next page.',
914
+ ` if (!state.loading) WidgetsBinding.instance.addPostFrameCallback((_) => context.read<${cubit}>().load());`,
915
+ ' return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));',
916
+ ' }',
917
+ ]
918
+ : []),
919
+ ` return ListTile(title: Text(${show('shown[index]')}));`,
920
+ ' },',
921
+ ' ),',
922
+ ' );',
923
+ ' }',
924
+ '}',
925
+ '',
926
+ ].join('\n');
927
+ pageText = addDartImport(pageText, importUri(page.path, n.cubitFile, c));
928
+ if (getIt && c.diFile)
929
+ pageText = addDartImport(pageText, importUri(page.path, c.diFile, c));
930
+ pageText = addDartImport(pageText, importUri(page.path, ep.file, c));
931
+ page.after = pageText;
932
+ const files = new Map(route.changes.filter((ch) => ch.after !== null).map((ch) => [ch.path, ch.after]));
933
+ if (exists(join(root, n.cubitFile)))
934
+ throw new ToolError('file_exists', `${n.cubitFile} already exists.`, 'Pick another screen name.');
935
+ files.set(n.cubitFile, cubitText);
936
+ const summary = [...route.summary, `Create ${n.cubitFile}: ${cubit} with items, loading, error${searchable ? ', query' : ''}${pg ? ' and hasMore' : ''}, calling ${sig.repoClass}.${ep.name}()`, `${page.path} lists it and ${pagingSummary(paging)}`];
937
+ return buildChangeSet(root, 'screen add', { files, summary, warnings: [...route.warnings, ...pagingWarnings(paging)], generated: [] });
938
+ }
472
939
  // ---------------- Next.js ----------------
473
940
  async function nextScreen(root, adapter, map, opts) {
474
941
  const route = await planRouteAdd(root, adapter, { name: opts.name, path: opts.path, initial: opts.initial });
@@ -523,28 +990,91 @@ async function nextScreen(root, adapter, map, opts) {
523
990
  ? [' <dl>', ...shape.model.fields.flatMap((f) => [` <dt>${f.name}</dt>`, ` <dd>{String(${dataVar}.${f.name} ?? '')}</dd>`]), ' </dl>']
524
991
  : [` <pre>{JSON.stringify(${dataVar}, null, 2)}</pre>`];
525
992
  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');
993
+ const pkgText = readText(join(root, 'package.json')) ?? '';
994
+ const major = Number(/"next"\s*:\s*"[^\d]*(\d+)/.exec(pkgText)?.[1] ?? 15);
995
+ let pagingInfo = null;
996
+ if (opts.paginate || opts.search) {
997
+ if (!shape.list)
998
+ throw new ToolError('screen_not_list', `${ep.name}() returns one ${shape.raw || 'value'}, not a list; --paginate and --search are for list screens.`);
999
+ const ps = splitTsParams(decl.params);
1000
+ const paging = detectPaging(tsPagingParams(ps), { paginate: opts.paginate, search: opts.search, modelStringFields: (shape.model?.fields ?? []).filter((f) => f.type === 'string').map((f) => f.name), titleField: tf });
1001
+ const routeArgs = new Map(ps.filter((x) => params.includes(x.name)).map((x) => [x.name, /^number$/.test(x.type) ? `Number(${x.name})` : x.name]));
1002
+ const searchable = Boolean(paging.search || paging.clientSearch);
1003
+ const offset = paging.page?.mode === 'offset';
1004
+ const urlKeys = [...(paging.paginate ? [offset ? 'offset' : 'page'] : []), ...(searchable ? ['q'] : [])];
1005
+ const sp = urlKeys.map((k) => `${k}?: string`).join('; ');
1006
+ const props = [...(params.length ? [`params: ${major >= 15 ? 'Promise<' : ''}{ ${params.map((x) => `${x}: string`).join('; ')} }${major >= 15 ? '>' : ''}`] : []), `searchParams: ${major >= 15 ? 'Promise<' : ''}{ ${sp} }${major >= 15 ? '>' : ''}`];
1007
+ const aw = major >= 15 ? 'await ' : '';
1008
+ const call = tsPagingArgs(ps, paging, routeArgs, { page: paging.paginate ? (offset ? 'offset' : 'page') : undefined, search: paging.search ? 'q || undefined' : undefined });
1009
+ const access = (v, f) => (/^[A-Za-z_$][\w$]*$/.test(f) ? `${v}.${f}` : `${v}['${f}']`);
1010
+ const more = paging.size ? 'items.length === PAGE_SIZE' : 'items.length > 0';
1011
+ const qs = (pageExpr) => `\`?\${new URLSearchParams({ ${searchable ? '...(q ? { q } : {}), ' : ''}${offset ? 'offset' : 'page'}: String(${pageExpr}) })}\``;
1012
+ page.after = [
1013
+ ...(paging.paginate ? [`import Link from ${q}next/link${q}${semi}`] : []),
1014
+ `import { ${ep.name} } from ${q}${from}${q}${semi}`,
1015
+ '',
1016
+ '// Rendered on each request: the data, the page and the search come from the request.',
1017
+ `export const dynamic = ${q}force-dynamic${q}${semi}`,
1018
+ ...(paging.paginate && paging.size ? [`const PAGE_SIZE = ${20}${semi}`] : []),
1019
+ '',
1020
+ `export default async function ${comp}({ ${[...(params.length ? ['params'] : []), 'searchParams'].join(', ')} }: { ${props.join('; ')} }) {`,
1021
+ ...(params.length ? [` const { ${params.join(', ')} } = ${aw}params${semi}`] : []),
1022
+ ` const { ${[...(paging.paginate ? [`${offset ? 'offset' : 'page'}: at`] : []), ...(searchable ? [`q = ${q}${q}`] : [])].join(', ')} } = ${aw}searchParams${semi}`,
1023
+ ...(paging.paginate ? [offset ? ` const offset = Math.max(0, Number(at) || 0)${semi}` : ` const page = Math.max(1, Number(at) || 1)${semi}`] : []),
1024
+ ` const items = await ${ep.name}(${call})${semi}`,
1025
+ ...(paging.clientSearch
1026
+ ? [` const needle = q.trim().toLowerCase()${semi}`, ` const shown = needle ? items.filter((item) => [${paging.searchFields.map((f) => access('item', f)).join(', ')}].some((v) => String(v ?? ${q}${q}).toLowerCase().includes(needle))) : items${semi}`]
1027
+ : [` const shown = items${semi}`]),
1028
+ ' return (',
1029
+ ' <main>',
1030
+ ` <h1>${title}</h1>`,
1031
+ ...(searchable ? [' <form role="search">', ' <input type="search" name="q" defaultValue={q} placeholder="Search" aria-label="Search" />', ' <button type="submit">Search</button>', ' </form>'] : []),
1032
+ ' {shown.length === 0 ? (',
1033
+ ` <p>${searchable ? `{q ? 'No matches.' : 'Nothing here yet.'}` : 'Nothing here yet.'}</p>`,
1034
+ ' ) : (',
1035
+ ' <ul>',
1036
+ ` {shown.map((item${idf ? '' : ', index'}) => (`,
1037
+ ` <li key={${idf ? `String(item.${idf})` : 'index'}}>{${tf ? `String(item.${tf})` : 'JSON.stringify(item)'}}</li>`,
1038
+ ' ))}',
1039
+ ' </ul>',
1040
+ ' )}',
1041
+ ...(paging.paginate
1042
+ ? [
1043
+ ' <nav aria-label="Pages">',
1044
+ offset ? ` {offset > 0 ? <Link href={${qs('0')}}>First page</Link> : null}` : ` {page > 1 ? <Link href={${qs('page - 1')}}>Previous</Link> : null}`,
1045
+ ` {${more} ? <Link href={${qs(offset ? 'offset + items.length' : 'page + 1')}}>Next</Link> : null}`,
1046
+ ' </nav>',
1047
+ ]
1048
+ : []),
1049
+ ' </main>',
1050
+ ` )${semi}`,
1051
+ '}',
1052
+ '',
1053
+ ].join('\n');
1054
+ pagingInfo = { summary: `${page.path} lists ${ep.name}() on the server and ${pagingSummary(paging, 'links')}`, warnings: pagingWarnings(paging) };
1055
+ }
1056
+ else
1057
+ page.after = [
1058
+ `import { ${ep.name} } from ${q}${from}${q}${semi}`,
1059
+ '',
1060
+ // Without route parameters Next.js would render the page once at build time,
1061
+ // calling the API then (and failing the build when it is not reachable).
1062
+ ...(params.length ? [] : ['// Rendered on each request: the data comes from the API.', `export const dynamic = ${q}force-dynamic${q}${semi}`, '']),
1063
+ `export default async function ${comp}(${sigParams}) {`,
1064
+ ...(params.length ? [` const { ${params.join(', ')} } = await params${semi}`] : []),
1065
+ ` const ${dataVar} = await ${ep.name}(${callArgs})${semi}`,
1066
+ ' return (',
1067
+ ' <main>',
1068
+ ` <h1>${title}</h1>`,
1069
+ ...render,
1070
+ ' </main>',
1071
+ ` )${semi}`,
1072
+ '}',
1073
+ '',
1074
+ ].join('\n');
544
1075
  // 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)
1076
+ const nextMajor = major;
1077
+ if (!pagingInfo && nextMajor < 15 && params.length)
548
1078
  page.after = page.after.replace(`params: Promise<{`, 'params: {').replace(/ }> }\) \{/, ' } }) {').replace(`= await params${semi}`, `= params${semi}`);
549
1079
  const folder = posix.dirname(page.path);
550
1080
  const ext = page.path.endsWith('.tsx') ? 'tsx' : 'jsx';
@@ -571,8 +1101,8 @@ async function nextScreen(root, adapter, map, opts) {
571
1101
  for (const [f, t] of extra)
572
1102
  if (!exists(join(root, f)))
573
1103
  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: [] });
1104
+ const summary = [...route.summary, pagingInfo?.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)`];
1105
+ return buildChangeSet(root, 'screen add', { files, summary, warnings: [...route.warnings, ...(pagingInfo?.warnings ?? [])], generated: [] });
576
1106
  }
577
1107
  export async function planScreenAdd(root, adapter, opts) {
578
1108
  const { map } = await loadMap(root, adapter, loadConfig(root).conventions);