codz-cli 0.1.0 → 0.1.3

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,1026 @@
1
+ // The first screen of a project made by `codz create`: a small task list that
2
+ // loads sample data and lets you tick tasks off, written in the architecture
3
+ // that was picked (a Riverpod AsyncNotifier, a GetX controller, a Cubit, a
4
+ // Hilt/Koin ViewModel, a TanStack Query hook...), so the app opens on a working
5
+ // example of its own pattern instead of the platform template (a setState
6
+ // counter). The data comes from a stand-in class, not the network; codz api add
7
+ // and feature add replace it with real endpoints.
8
+ import { join } from 'node:path';
9
+ import { addKotlinImports } from "../adapters/kotlinGen.js";
10
+ import { readText } from "../core/fsutil.js";
11
+ import { matchBrace } from "../adapters/source.js";
12
+ const TASKS = [
13
+ { id: 1, title: 'Read the codz guide', done: true },
14
+ { id: 2, title: 'Add your API with codz feature add', done: false },
15
+ { id: 3, title: 'Ship the first screen', done: false },
16
+ ];
17
+ const flutterTitle = (name) => name.split('_').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
18
+ /** The demo screen for a new project, or null when the architecture has none (none, or not known). */
19
+ export function planDemo(d) {
20
+ if (d.architecture === 'none')
21
+ return null;
22
+ const read = (p) => readText(join(d.dir, p));
23
+ const plan = { files: new Map(), rewrites: [], summary: [] };
24
+ const write = (path, content) => {
25
+ if (read(path) !== null)
26
+ plan.rewrites.push(path);
27
+ plan.files.set(path, content);
28
+ };
29
+ if (d.type === 'flutter')
30
+ return flutterDemo(d, read, write, plan);
31
+ if (d.type === 'nextjs')
32
+ return nextDemo(d, read, write, plan);
33
+ if (d.type === 'react-native')
34
+ return reactNativeDemo(d, read, write, plan);
35
+ if (d.type === 'android')
36
+ return androidDemo(d, read, write, plan);
37
+ if (d.type === 'react')
38
+ return reactDemo(d, read, write, plan);
39
+ if (d.type === 'ios')
40
+ return iosDemo(d, read, write, plan);
41
+ return null;
42
+ }
43
+ // ---------------------------------------------------------------- Flutter
44
+ function dartTasksSource() {
45
+ return [
46
+ '/// One row of the demo list.',
47
+ 'class DemoTask {',
48
+ ' const DemoTask({required this.id, required this.title, this.done = false});',
49
+ '',
50
+ ' final int id;',
51
+ ' final String title;',
52
+ ' final bool done;',
53
+ '',
54
+ ' DemoTask copyWith({bool? done}) => DemoTask(id: id, title: title, done: done ?? this.done);',
55
+ '}',
56
+ '',
57
+ '/// Stands in for an API: returns sample tasks after a short delay.',
58
+ '/// Replace it with real endpoints: codz api add, or codz feature add <name> --spec api.yaml.',
59
+ 'class DemoTaskSource {',
60
+ ' Future<List<DemoTask>> load() async {',
61
+ ' await Future<void>.delayed(const Duration(milliseconds: 400));',
62
+ ' return const [',
63
+ ...TASKS.map((t) => ` DemoTask(id: ${t.id}, title: '${t.title}'${t.done ? ', done: true' : ''}),`),
64
+ ' ];',
65
+ ' }',
66
+ '}',
67
+ '',
68
+ ].join('\n');
69
+ }
70
+ const dartIntro = (text) => [` const Padding(`, ` padding: EdgeInsets.fromLTRB(16, 16, 16, 8),`, ` child: Text('${text}'),`, ' ),'];
71
+ function flutterMain(d, arch) {
72
+ const title = flutterTitle(d.name);
73
+ const pkg = `package:${d.name}`;
74
+ const theme = [' theme: ThemeData(', ' colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),', ' useMaterial3: true,', ' ),'];
75
+ if (arch === 'getx') {
76
+ return [
77
+ `import '${pkg}/app/bindings/initial_binding.dart';`,
78
+ `import '${pkg}/app/routes/app_pages.dart';`,
79
+ "import 'package:flutter/material.dart';",
80
+ "import 'package:get/get.dart';",
81
+ '',
82
+ 'void main() {',
83
+ ' runApp(const MyApp());',
84
+ '}',
85
+ '',
86
+ 'class MyApp extends StatelessWidget {',
87
+ ' const MyApp({super.key});',
88
+ '',
89
+ ' @override',
90
+ ' Widget build(BuildContext context) {',
91
+ ' return GetMaterialApp(',
92
+ ' initialBinding: InitialBinding(),',
93
+ ' initialRoute: AppPages.initial,',
94
+ ' getPages: AppPages.routes,',
95
+ ` title: '${title}',`,
96
+ ...theme,
97
+ ' );',
98
+ ' }',
99
+ '}',
100
+ '',
101
+ ].join('\n');
102
+ }
103
+ const di = arch === 'bloc' || arch === 'get_it';
104
+ return [
105
+ ...(di ? [`import '${pkg}/core/di/injection.dart';`] : []),
106
+ `import '${pkg}/features/home/home_page.dart';`,
107
+ "import 'package:flutter/material.dart';",
108
+ ...(arch === 'riverpod' ? ["import 'package:flutter_riverpod/flutter_riverpod.dart';"] : []),
109
+ '',
110
+ 'void main() {',
111
+ ...(di ? [' setupDependencies();'] : []),
112
+ ` runApp(${arch === 'riverpod' ? 'const ProviderScope(child: MyApp())' : 'const MyApp()'});`,
113
+ '}',
114
+ '',
115
+ 'class MyApp extends StatelessWidget {',
116
+ ' const MyApp({super.key});',
117
+ '',
118
+ ' @override',
119
+ ' Widget build(BuildContext context) {',
120
+ ' return MaterialApp(',
121
+ ` title: '${title}',`,
122
+ ...theme,
123
+ ' home: const HomePage(),',
124
+ ' );',
125
+ ' }',
126
+ '}',
127
+ '',
128
+ ]
129
+ .join('\n')
130
+ .replace(/^(import [^\n]+\n)+/, (block) => block.split('\n').filter(Boolean).sort().join('\n') + '\n');
131
+ }
132
+ function flutterTest(d, arch) {
133
+ const di = arch === 'bloc' || arch === 'get_it';
134
+ return [
135
+ ...(di ? [`import 'package:${d.name}/core/di/injection.dart';`] : []),
136
+ `import 'package:${d.name}/main.dart';`,
137
+ ...(arch === 'riverpod' ? ["import 'package:flutter_riverpod/flutter_riverpod.dart';"] : []),
138
+ "import 'package:flutter_test/flutter_test.dart';",
139
+ '',
140
+ 'void main() {',
141
+ " testWidgets('the home page lists the demo tasks', (WidgetTester tester) async {",
142
+ ...(di ? [' setupDependencies();'] : []),
143
+ ` await tester.pumpWidget(${arch === 'riverpod' ? 'const ProviderScope(child: MyApp())' : 'const MyApp()'});`,
144
+ ' await tester.pump(const Duration(seconds: 1));',
145
+ ' await tester.pump();',
146
+ ` expect(find.text('${TASKS[2].title}'), findsOneWidget);`,
147
+ ' });',
148
+ '}',
149
+ '',
150
+ ].join('\n');
151
+ }
152
+ const checkboxRow = (task, onChanged) => [
153
+ ` CheckboxListTile(`,
154
+ ` value: ${task}.done,`,
155
+ ` title: Text(${task}.title),`,
156
+ ` onChanged: (_) => ${onChanged},`,
157
+ ' ),',
158
+ ];
159
+ function flutterDemo(d, read, write, plan) {
160
+ const arch = d.architecture;
161
+ const pkg = `package:${d.name}`;
162
+ const title = flutterTitle(d.name);
163
+ if (arch === 'riverpod') {
164
+ write('lib/features/home/demo_tasks.dart', dartTasksSource());
165
+ write('lib/features/home/home_controller.dart', [
166
+ "import 'package:flutter_riverpod/flutter_riverpod.dart';",
167
+ `import '${pkg}/features/home/demo_tasks.dart';`,
168
+ '',
169
+ 'final demoTaskSourceProvider = Provider<DemoTaskSource>((ref) => DemoTaskSource());',
170
+ '',
171
+ '/// Holds the task list: loading, then the tasks (or the error).',
172
+ 'final homeControllerProvider = AsyncNotifierProvider<HomeController, List<DemoTask>>(HomeController.new);',
173
+ '',
174
+ 'class HomeController extends AsyncNotifier<List<DemoTask>> {',
175
+ ' @override',
176
+ ' Future<List<DemoTask>> build() => ref.read(demoTaskSourceProvider).load();',
177
+ '',
178
+ ' Future<void> reload() async {',
179
+ ' state = const AsyncLoading();',
180
+ ' state = await AsyncValue.guard(() => ref.read(demoTaskSourceProvider).load());',
181
+ ' }',
182
+ '',
183
+ ' void toggle(int id) {',
184
+ ' final tasks = state.valueOrNull;',
185
+ ' if (tasks == null) return;',
186
+ ' state = AsyncData([for (final t in tasks) t.id == id ? t.copyWith(done: !t.done) : t]);',
187
+ ' }',
188
+ '}',
189
+ '',
190
+ ].join('\n'));
191
+ write('lib/features/home/home_page.dart', [
192
+ "import 'package:flutter/material.dart';",
193
+ "import 'package:flutter_riverpod/flutter_riverpod.dart';",
194
+ `import '${pkg}/features/home/home_controller.dart';`,
195
+ '',
196
+ 'class HomePage extends ConsumerWidget {',
197
+ ' const HomePage({super.key});',
198
+ '',
199
+ ' @override',
200
+ ' Widget build(BuildContext context, WidgetRef ref) {',
201
+ ' final tasks = ref.watch(homeControllerProvider);',
202
+ ' final controller = ref.read(homeControllerProvider.notifier);',
203
+ ' return Scaffold(',
204
+ ` appBar: AppBar(title: const Text('${title}')),`,
205
+ ' body: tasks.when(',
206
+ ' loading: () => const Center(child: CircularProgressIndicator()),',
207
+ " error: (error, _) => Center(child: Text('$error')),",
208
+ ' data: (items) => RefreshIndicator(',
209
+ ' onRefresh: controller.reload,',
210
+ ' child: ListView(',
211
+ ' children: [',
212
+ ...dartIntro('Riverpod: HomeController (an AsyncNotifier) holds the list; this page watches it.').map((l) => ` ${l}`),
213
+ ' for (final task in items)',
214
+ ...checkboxRow('task', 'controller.toggle(task.id)').map((l) => ` ${l}`),
215
+ ' ],',
216
+ ' ),',
217
+ ' ),',
218
+ ' ),',
219
+ ' );',
220
+ ' }',
221
+ '}',
222
+ '',
223
+ ].join('\n'));
224
+ }
225
+ else if (arch === 'bloc') {
226
+ write('lib/features/home/demo_tasks.dart', dartTasksSource());
227
+ write('lib/features/home/home_cubit.dart', [
228
+ "import 'package:flutter_bloc/flutter_bloc.dart';",
229
+ `import '${pkg}/features/home/demo_tasks.dart';`,
230
+ '',
231
+ 'sealed class HomeState {',
232
+ ' const HomeState();',
233
+ '}',
234
+ '',
235
+ 'final class HomeLoading extends HomeState {',
236
+ ' const HomeLoading();',
237
+ '}',
238
+ '',
239
+ 'final class HomeLoaded extends HomeState {',
240
+ ' const HomeLoaded(this.tasks);',
241
+ '',
242
+ ' final List<DemoTask> tasks;',
243
+ '}',
244
+ '',
245
+ 'final class HomeFailure extends HomeState {',
246
+ ' const HomeFailure(this.message);',
247
+ '',
248
+ ' final String message;',
249
+ '}',
250
+ '',
251
+ 'class HomeCubit extends Cubit<HomeState> {',
252
+ ' HomeCubit(this._source) : super(const HomeLoading());',
253
+ '',
254
+ ' final DemoTaskSource _source;',
255
+ '',
256
+ ' Future<void> load() async {',
257
+ ' emit(const HomeLoading());',
258
+ ' try {',
259
+ ' emit(HomeLoaded(await _source.load()));',
260
+ ' } catch (e) {',
261
+ ' emit(HomeFailure(e.toString()));',
262
+ ' }',
263
+ ' }',
264
+ '',
265
+ ' void toggle(int id) {',
266
+ ' final s = state;',
267
+ ' if (s is! HomeLoaded) return;',
268
+ ' emit(HomeLoaded([for (final t in s.tasks) t.id == id ? t.copyWith(done: !t.done) : t]));',
269
+ ' }',
270
+ '}',
271
+ '',
272
+ ].join('\n'));
273
+ write('lib/features/home/home_page.dart', [
274
+ "import 'package:flutter/material.dart';",
275
+ "import 'package:flutter_bloc/flutter_bloc.dart';",
276
+ `import '${pkg}/core/di/injection.dart';`,
277
+ `import '${pkg}/features/home/demo_tasks.dart';`,
278
+ `import '${pkg}/features/home/home_cubit.dart';`,
279
+ '',
280
+ 'class HomePage extends StatelessWidget {',
281
+ ' const HomePage({super.key});',
282
+ '',
283
+ ' @override',
284
+ ' Widget build(BuildContext context) {',
285
+ ' return BlocProvider(',
286
+ ' create: (_) => HomeCubit(getIt<DemoTaskSource>())..load(),',
287
+ ' child: Scaffold(',
288
+ ` appBar: AppBar(title: const Text('${title}')),`,
289
+ ' body: BlocBuilder<HomeCubit, HomeState>(',
290
+ ' builder: (context, state) => switch (state) {',
291
+ ' HomeLoading() => const Center(child: CircularProgressIndicator()),',
292
+ ' HomeFailure(:final message) => Center(child: Text(message)),',
293
+ ' HomeLoaded(:final tasks) => RefreshIndicator(',
294
+ ' onRefresh: context.read<HomeCubit>().load,',
295
+ ' child: ListView(',
296
+ ' children: [',
297
+ ...dartIntro('Bloc: HomeCubit holds the list; BlocBuilder shows each state.').map((l) => ` ${l}`),
298
+ ' for (final task in tasks)',
299
+ ...checkboxRow('task', 'context.read<HomeCubit>().toggle(task.id)').map((l) => ` ${l}`),
300
+ ' ],',
301
+ ' ),',
302
+ ' ),',
303
+ ' },',
304
+ ' ),',
305
+ ' ),',
306
+ ' );',
307
+ ' }',
308
+ '}',
309
+ '',
310
+ ].join('\n'));
311
+ registerGetIt(read, write, pkg);
312
+ }
313
+ else if (arch === 'get_it') {
314
+ write('lib/features/home/demo_tasks.dart', dartTasksSource());
315
+ write('lib/features/home/home_page.dart', [
316
+ "import 'package:flutter/material.dart';",
317
+ `import '${pkg}/core/di/injection.dart';`,
318
+ `import '${pkg}/features/home/demo_tasks.dart';`,
319
+ '',
320
+ 'class HomePage extends StatefulWidget {',
321
+ ' const HomePage({super.key});',
322
+ '',
323
+ ' @override',
324
+ ' State<HomePage> createState() => _HomePageState();',
325
+ '}',
326
+ '',
327
+ 'class _HomePageState extends State<HomePage> {',
328
+ ' final _source = getIt<DemoTaskSource>();',
329
+ ' List<DemoTask>? _tasks;',
330
+ '',
331
+ ' @override',
332
+ ' void initState() {',
333
+ ' super.initState();',
334
+ ' _load();',
335
+ ' }',
336
+ '',
337
+ ' Future<void> _load() async {',
338
+ ' final tasks = await _source.load();',
339
+ ' if (mounted) setState(() => _tasks = tasks);',
340
+ ' }',
341
+ '',
342
+ ' void _toggle(int id) {',
343
+ ' setState(() => _tasks = [for (final t in _tasks!) t.id == id ? t.copyWith(done: !t.done) : t]);',
344
+ ' }',
345
+ '',
346
+ ' @override',
347
+ ' Widget build(BuildContext context) {',
348
+ ' final tasks = _tasks;',
349
+ ' return Scaffold(',
350
+ ` appBar: AppBar(title: const Text('${title}')),`,
351
+ ' body: tasks == null',
352
+ ' ? const Center(child: CircularProgressIndicator())',
353
+ ' : RefreshIndicator(',
354
+ ' onRefresh: _load,',
355
+ ' child: ListView(',
356
+ ' children: [',
357
+ ...dartIntro('get_it: the page takes DemoTaskSource from the service locator.').map((l) => ` ${l}`),
358
+ ' for (final task in tasks)',
359
+ ...checkboxRow('task', '_toggle(task.id)').map((l) => ` ${l}`),
360
+ ' ],',
361
+ ' ),',
362
+ ' ),',
363
+ ' );',
364
+ ' }',
365
+ '}',
366
+ '',
367
+ ].join('\n'));
368
+ registerGetIt(read, write, pkg);
369
+ }
370
+ else if (arch === 'getx') {
371
+ // The module itself (binding, controller, view, route) comes from module add; these are its contents.
372
+ write('lib/app/data/demo_tasks.dart', dartTasksSource());
373
+ write('lib/app/modules/home/bindings/home_binding.dart', [
374
+ `import '${pkg}/app/data/demo_tasks.dart';`,
375
+ `import '${pkg}/app/modules/home/controllers/home_controller.dart';`,
376
+ "import 'package:get/get.dart';",
377
+ '',
378
+ 'class HomeBinding extends Bindings {',
379
+ ' @override',
380
+ ' void dependencies() {',
381
+ ' Get.lazyPut<DemoTaskSource>(() => DemoTaskSource());',
382
+ ' Get.lazyPut<HomeController>(() => HomeController());',
383
+ ' }',
384
+ '}',
385
+ '',
386
+ ].join('\n'));
387
+ write('lib/app/modules/home/controllers/home_controller.dart', [
388
+ `import '${pkg}/app/data/demo_tasks.dart';`,
389
+ "import 'package:get/get.dart';",
390
+ '',
391
+ 'class HomeController extends GetxController {',
392
+ ' final DemoTaskSource _source = Get.find<DemoTaskSource>();',
393
+ '',
394
+ ' final tasks = <DemoTask>[].obs;',
395
+ ' final isLoading = false.obs;',
396
+ '',
397
+ ' @override',
398
+ ' void onInit() {',
399
+ ' super.onInit();',
400
+ ' load();',
401
+ ' }',
402
+ '',
403
+ ' Future<void> load() async {',
404
+ ' isLoading.value = true;',
405
+ ' tasks.assignAll(await _source.load());',
406
+ ' isLoading.value = false;',
407
+ ' }',
408
+ '',
409
+ ' void toggle(int id) {',
410
+ ' final i = tasks.indexWhere((t) => t.id == id);',
411
+ ' if (i >= 0) tasks[i] = tasks[i].copyWith(done: !tasks[i].done);',
412
+ ' }',
413
+ '}',
414
+ '',
415
+ ].join('\n'));
416
+ write('lib/app/modules/home/views/home_view.dart', [
417
+ `import '${pkg}/app/modules/home/controllers/home_controller.dart';`,
418
+ "import 'package:flutter/material.dart';",
419
+ "import 'package:get/get.dart';",
420
+ '',
421
+ 'class HomeView extends GetView<HomeController> {',
422
+ ' const HomeView({super.key});',
423
+ '',
424
+ ' @override',
425
+ ' Widget build(BuildContext context) {',
426
+ ' return Scaffold(',
427
+ ` appBar: AppBar(title: const Text('${title}')),`,
428
+ ' body: Obx(() {',
429
+ ' if (controller.isLoading.value) return const Center(child: CircularProgressIndicator());',
430
+ ' return RefreshIndicator(',
431
+ ' onRefresh: controller.load,',
432
+ ' child: ListView(',
433
+ ' children: [',
434
+ ...dartIntro('GetX: HomeController holds the list as an RxList; Obx rebuilds this view.'),
435
+ ' for (final task in controller.tasks)',
436
+ ...checkboxRow('task', 'controller.toggle(task.id)').map((l) => ` ${l}`),
437
+ ' ],',
438
+ ' ),',
439
+ ' );',
440
+ ' }),',
441
+ ' );',
442
+ ' }',
443
+ '}',
444
+ '',
445
+ ].join('\n'));
446
+ }
447
+ else
448
+ return null;
449
+ write('lib/main.dart', flutterMain(d, arch));
450
+ write('test/widget_test.dart', flutterTest(d, arch));
451
+ const label = { riverpod: 'a Riverpod AsyncNotifier', bloc: 'a Cubit with BlocBuilder', get_it: 'a page that takes its data source from get_it', getx: 'a GetX module (binding, controller with an RxList, Obx view)' }[arch];
452
+ plan.summary.push(`First screen: a demo task list built with ${label}, on sample data (no network)`);
453
+ return plan;
454
+ }
455
+ function registerGetIt(read, write, pkg) {
456
+ const file = 'lib/core/di/injection.dart';
457
+ const text = read(file);
458
+ if (text === null || text.includes('DemoTaskSource'))
459
+ return;
460
+ const m = /void setupDependencies\(\)\s*\{/.exec(text);
461
+ if (!m)
462
+ return;
463
+ const close = matchBrace(text, m.index + m[0].length - 1, 'dart');
464
+ let t = `${text.slice(0, close).replace(/\s*$/, '')}\n getIt.registerLazySingleton<DemoTaskSource>(DemoTaskSource.new);\n${text.slice(close)}`;
465
+ const imp = `import '${pkg}/features/home/demo_tasks.dart';`;
466
+ const imports = [...t.matchAll(/^import [^\n]+;$/gm)].map((x) => x[0]);
467
+ const sorted = [...imports, imp].sort();
468
+ t = t.replace(imports.join('\n'), sorted.join('\n'));
469
+ write(file, t);
470
+ }
471
+ // ---------------------------------------------------------------- Next.js
472
+ function nextDemo(d, read, write, plan) {
473
+ const helper = d.architecture === 'fetch-helper';
474
+ write('lib/demo-tasks.ts', [
475
+ 'export interface DemoTask {',
476
+ ' id: number;',
477
+ ' title: string;',
478
+ ' done: boolean;',
479
+ '}',
480
+ '',
481
+ '/**',
482
+ ' * Stands in for an API: returns sample tasks after a short delay.',
483
+ ` * Replace it with real endpoints (codz api add, or codz feature add <name> --spec api.yaml), for example ${helper ? "apiFetch<DemoTask[]>('/tasks')" : "fetch(`${process.env.NEXT_PUBLIC_API_URL}/tasks`)"}.`,
484
+ ' */',
485
+ 'export async function getDemoTasks(): Promise<DemoTask[]> {',
486
+ ' await new Promise((resolve) => setTimeout(resolve, 200));',
487
+ ' return [',
488
+ ...TASKS.map((t) => ` { id: ${t.id}, title: '${t.title}', done: ${t.done} },`),
489
+ ' ];',
490
+ '}',
491
+ '',
492
+ ].join('\n'));
493
+ write('app/demo-task-list.tsx', [
494
+ "'use client';",
495
+ '',
496
+ "import { useState } from 'react';",
497
+ "import type { DemoTask } from '../lib/demo-tasks';",
498
+ '',
499
+ '/** The list in the browser: ticking a task is client state. */',
500
+ 'export default function DemoTaskList({ initial }: { initial: DemoTask[] }) {',
501
+ ' const [tasks, setTasks] = useState(initial);',
502
+ ' const toggle = (id: number) => setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));',
503
+ ' return (',
504
+ " <ul style={{ listStyle: 'none', padding: 0 }}>",
505
+ ' {tasks.map((task) => (',
506
+ ' <li key={task.id}>',
507
+ ' <label>',
508
+ ' <input type="checkbox" checked={task.done} onChange={() => toggle(task.id)} /> {task.title}',
509
+ ' </label>',
510
+ ' </li>',
511
+ ' ))}',
512
+ ' </ul>',
513
+ ' );',
514
+ '}',
515
+ '',
516
+ ].join('\n'));
517
+ write('app/page.tsx', [
518
+ "import { getDemoTasks } from '../lib/demo-tasks';",
519
+ "import DemoTaskList from './demo-task-list';",
520
+ '',
521
+ '/** A server component loads the data; the client component handles the clicks. */',
522
+ 'export default async function Home() {',
523
+ ' const tasks = await getDemoTasks();',
524
+ ' return (',
525
+ " <main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>",
526
+ ` <h1>${d.name}</h1>`,
527
+ ` <p>The page loads tasks on the server (${helper ? 'API functions in lib/api call apiFetch' : 'API functions in lib/api call fetch'}); ticking one is client state.</p>`,
528
+ ' <DemoTaskList initial={tasks} />',
529
+ ' </main>',
530
+ ' );',
531
+ '}',
532
+ '',
533
+ ].join('\n'));
534
+ void read;
535
+ plan.summary.push('First screen: a demo task list (server component loads it, a client component ticks tasks off), on sample data (no network)');
536
+ return plan;
537
+ }
538
+ // ---------------------------------------------------------------- React Native
539
+ function reactNativeDemo(d, read, write, plan) {
540
+ const query = d.architecture === 'axios-react-query';
541
+ if (read('src/screens/HomeScreen.tsx') === null)
542
+ return null;
543
+ write('src/demo/demoTasks.ts', [
544
+ 'export interface DemoTask {',
545
+ ' id: number;',
546
+ ' title: string;',
547
+ ' done: boolean;',
548
+ '}',
549
+ '',
550
+ '/**',
551
+ ' * Stands in for an API: returns sample tasks after a short delay.',
552
+ ` * Replace it with real endpoints (codz api add, or codz feature add <name> --spec api.yaml), for example ${d.architecture === 'fetch-helper' ? "apiFetch<DemoTask[]>('/tasks')" : "(await api.get<DemoTask[]>('/tasks')).data"}.`,
553
+ ' */',
554
+ 'export async function getDemoTasks(): Promise<DemoTask[]> {',
555
+ ' await new Promise((resolve) => setTimeout(resolve, 400));',
556
+ ' return [',
557
+ ...TASKS.map((t) => ` { id: ${t.id}, title: '${t.title}', done: ${t.done} },`),
558
+ ' ];',
559
+ '}',
560
+ '',
561
+ ].join('\n'));
562
+ const intro = query ? 'TanStack Query loads the tasks (useQuery); ticking one is local state.' : 'The screen loads the tasks in an effect and keeps them in state.';
563
+ const body = query
564
+ ? [
565
+ "import { useQuery } from '@tanstack/react-query';",
566
+ "import { useState } from 'react';",
567
+ "import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';",
568
+ "import { getDemoTasks } from '../demo/demoTasks';",
569
+ '',
570
+ 'export default function HomeScreen() {',
571
+ " const { data, isLoading, error, refetch, isRefetching } = useQuery({ queryKey: ['demo-tasks'], queryFn: getDemoTasks });",
572
+ ' const [flipped, setFlipped] = useState<Record<number, boolean>>({});',
573
+ ' if (isLoading) {',
574
+ ' return (',
575
+ ' <View style={styles.center}>',
576
+ ' <ActivityIndicator />',
577
+ ' </View>',
578
+ ' );',
579
+ ' }',
580
+ ' if (error || !data) {',
581
+ ' return (',
582
+ ' <View style={styles.center}>',
583
+ ' <Text>{error instanceof Error ? error.message : String(error)}</Text>',
584
+ ' </View>',
585
+ ' );',
586
+ ' }',
587
+ ' return (',
588
+ ' <FlatList',
589
+ ' data={data}',
590
+ ' keyExtractor={(task) => String(task.id)}',
591
+ ' refreshing={isRefetching}',
592
+ ' onRefresh={() => refetch()}',
593
+ ` ListHeaderComponent={<Text style={styles.intro}>${intro}</Text>}`,
594
+ ' renderItem={({ item }) => {',
595
+ ' const done = flipped[item.id] ? !item.done : item.done;',
596
+ ' return (',
597
+ ' <Pressable accessibilityRole="checkbox" accessibilityState={{ checked: done }} style={styles.row} onPress={() => setFlipped((f) => ({ ...f, [item.id]: !f[item.id] }))}>',
598
+ " <Text style={styles.text}>{done ? '☑' : '☐'} {item.title}</Text>",
599
+ ' </Pressable>',
600
+ ' );',
601
+ ' }}',
602
+ ' />',
603
+ ' );',
604
+ '}',
605
+ ]
606
+ : [
607
+ "import { useCallback, useEffect, useState } from 'react';",
608
+ "import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';",
609
+ "import { getDemoTasks, type DemoTask } from '../demo/demoTasks';",
610
+ '',
611
+ 'export default function HomeScreen() {',
612
+ ' const [tasks, setTasks] = useState<DemoTask[] | null>(null);',
613
+ ' const [refreshing, setRefreshing] = useState(false);',
614
+ ' const load = useCallback(async () => {',
615
+ ' setTasks(await getDemoTasks());',
616
+ ' }, []);',
617
+ '',
618
+ ' useEffect(() => {',
619
+ ' load();',
620
+ ' }, [load]);',
621
+ '',
622
+ ' const toggle = (id: number) => setTasks((ts) => ts && ts.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));',
623
+ ' if (!tasks) {',
624
+ ' return (',
625
+ ' <View style={styles.center}>',
626
+ ' <ActivityIndicator />',
627
+ ' </View>',
628
+ ' );',
629
+ ' }',
630
+ ' return (',
631
+ ' <FlatList',
632
+ ' data={tasks}',
633
+ ' keyExtractor={(task) => String(task.id)}',
634
+ ' refreshing={refreshing}',
635
+ ' onRefresh={async () => {',
636
+ ' setRefreshing(true);',
637
+ ' await load();',
638
+ ' setRefreshing(false);',
639
+ ' }}',
640
+ ` ListHeaderComponent={<Text style={styles.intro}>${intro}</Text>}`,
641
+ ' renderItem={({ item }) => (',
642
+ ' <Pressable accessibilityRole="checkbox" accessibilityState={{ checked: item.done }} style={styles.row} onPress={() => toggle(item.id)}>',
643
+ " <Text style={styles.text}>{item.done ? '☑' : '☐'} {item.title}</Text>",
644
+ ' </Pressable>',
645
+ ' )}',
646
+ ' />',
647
+ ' );',
648
+ '}',
649
+ ];
650
+ write('src/screens/HomeScreen.tsx', [
651
+ ...body,
652
+ '',
653
+ 'const styles = StyleSheet.create({',
654
+ " center: { flex: 1, alignItems: 'center', justifyContent: 'center' },",
655
+ ' intro: { padding: 16, color: \'#555\' },',
656
+ " row: { paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#ccc' },",
657
+ ' text: { fontSize: 16 },',
658
+ '});',
659
+ '',
660
+ ].join('\n'));
661
+ plan.summary.push(`First screen: a demo task list ${query ? 'loaded with TanStack Query (useQuery)' : 'loaded in an effect and kept in state'}, on sample data (no network)`);
662
+ return plan;
663
+ }
664
+ // ---------------------------------------------------------------- Android
665
+ function androidDemo(d, read, write, plan) {
666
+ const pkg = d.androidPackage;
667
+ if (!pkg)
668
+ return null;
669
+ const base = `app/src/main/java/${pkg.replace(/\./g, '/')}`;
670
+ if (read(`${base}/ui/screens/HomeScreen.kt`) === null)
671
+ return null;
672
+ const arch = d.architecture;
673
+ const hilt = arch === 'retrofit-hilt';
674
+ const koin = arch === 'retrofit-koin';
675
+ const title = flutterTitle(d.name.replace(/[^a-z0-9]+/gi, '_').toLowerCase());
676
+ if (read(`${base}/ui/UiState.kt`) === null) {
677
+ write(`${base}/ui/UiState.kt`, `package ${pkg}.ui\n\n/** What a screen shows while it loads data, when it has it, and when loading failed. */\nsealed interface UiState<out T> {\n data object Loading : UiState<Nothing>\n\n data class Success<T>(val data: T) : UiState<T>\n\n data class Error(val message: String) : UiState<Nothing>\n}\n`);
678
+ }
679
+ write(`${base}/data/demo/DemoTaskSource.kt`, [
680
+ `package ${pkg}.data.demo`,
681
+ '',
682
+ ...(hilt ? ['import javax.inject.Inject'] : []),
683
+ 'import kotlinx.coroutines.delay',
684
+ '',
685
+ '/** One row of the demo list. */',
686
+ 'data class DemoTask(val id: Int, val title: String, val done: Boolean = false)',
687
+ '',
688
+ '/**',
689
+ ' * Stands in for an API: returns sample tasks after a short delay.',
690
+ ' * Replace it with real endpoints: codz api add, or codz feature add <name> --spec api.yaml.',
691
+ ' */',
692
+ `class DemoTaskSource${hilt ? ' @Inject constructor()' : ''} {`,
693
+ ' suspend fun load(): List<DemoTask> {',
694
+ ' delay(400)',
695
+ ' return listOf(',
696
+ ...TASKS.map((t) => ` DemoTask(${t.id}, "${t.title}"${t.done ? ', done = true' : ''}),`),
697
+ ' )',
698
+ ' }',
699
+ '}',
700
+ '',
701
+ ].join('\n'));
702
+ const vmHeader = hilt ? ['@HiltViewModel', 'class HomeViewModel @Inject constructor(private val source: DemoTaskSource) : ViewModel() {'] : ['class HomeViewModel(private val source: DemoTaskSource) : ViewModel() {'];
703
+ const vm = [
704
+ `package ${pkg}.ui.screens`,
705
+ '',
706
+ ...vmHeader,
707
+ ' private val _state = MutableStateFlow<UiState<List<DemoTask>>>(UiState.Loading)',
708
+ ' val state: StateFlow<UiState<List<DemoTask>>> = _state.asStateFlow()',
709
+ '',
710
+ ' init {',
711
+ ' load()',
712
+ ' }',
713
+ '',
714
+ ' fun load() {',
715
+ ' viewModelScope.launch {',
716
+ ' _state.value = UiState.Loading',
717
+ ' _state.value = try {',
718
+ ' UiState.Success(source.load())',
719
+ ' } catch (e: Exception) {',
720
+ ' UiState.Error(e.message ?: "Something went wrong")',
721
+ ' }',
722
+ ' }',
723
+ ' }',
724
+ '',
725
+ ' fun toggle(id: Int) {',
726
+ ' val current = _state.value as? UiState.Success ?: return',
727
+ ' _state.value = UiState.Success(current.data.map { if (it.id == id) it.copy(done = !it.done) else it })',
728
+ ' }',
729
+ ...(!hilt && !koin ? ['', ' companion object {', ' val Factory: ViewModelProvider.Factory = viewModelFactory {', ' initializer { HomeViewModel(DemoTaskSource()) }', ' }', ' }'] : []),
730
+ '}',
731
+ '',
732
+ ].join('\n');
733
+ write(`${base}/ui/screens/HomeViewModel.kt`, addKotlinImports(vm, [
734
+ 'androidx.lifecycle.ViewModel',
735
+ 'androidx.lifecycle.viewModelScope',
736
+ 'kotlinx.coroutines.flow.MutableStateFlow',
737
+ 'kotlinx.coroutines.flow.StateFlow',
738
+ 'kotlinx.coroutines.flow.asStateFlow',
739
+ 'kotlinx.coroutines.launch',
740
+ `${pkg}.data.demo.DemoTask`,
741
+ `${pkg}.data.demo.DemoTaskSource`,
742
+ `${pkg}.ui.UiState`,
743
+ ...(hilt ? ['dagger.hilt.android.lifecycle.HiltViewModel', 'javax.inject.Inject'] : []),
744
+ ...(!hilt && !koin ? ['androidx.lifecycle.ViewModelProvider', 'androidx.lifecycle.viewmodel.initializer', 'androidx.lifecycle.viewmodel.viewModelFactory'] : []),
745
+ ], `${pkg}.ui.screens`));
746
+ const vmDefault = hilt ? 'hiltViewModel()' : koin ? 'koinViewModel()' : 'viewModel(factory = HomeViewModel.Factory)';
747
+ const intro = hilt ? 'Hilt injects HomeViewModel; the screen collects its StateFlow.' : koin ? 'Koin provides HomeViewModel; the screen collects its StateFlow.' : 'HomeViewModel comes from its Factory; the screen collects its StateFlow.';
748
+ const screen = [
749
+ `package ${pkg}.ui.screens`,
750
+ '',
751
+ '@OptIn(ExperimentalMaterial3Api::class)',
752
+ '@Composable',
753
+ `fun HomeScreen(viewModel: HomeViewModel = ${vmDefault}) {`,
754
+ ' val state by viewModel.state.collectAsState()',
755
+ ` Scaffold(topBar = { TopAppBar(title = { Text("${title}") }) }) { padding ->`,
756
+ ' Box(Modifier.fillMaxSize().padding(padding)) {',
757
+ ' when (val s = state) {',
758
+ ' is UiState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))',
759
+ ' is UiState.Error -> Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {',
760
+ ' Text(s.message, color = MaterialTheme.colorScheme.error)',
761
+ ' Button(onClick = viewModel::load) { Text("Retry") }',
762
+ ' }',
763
+ ' is UiState.Success -> LazyColumn(Modifier.fillMaxSize()) {',
764
+ ` item { Text("${intro}", Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium) }`,
765
+ ' items(s.data, key = { it.id }) { task ->',
766
+ ' Row(',
767
+ ' Modifier.fillMaxWidth().clickable { viewModel.toggle(task.id) }.padding(horizontal = 16.dp, vertical = 8.dp),',
768
+ ' verticalAlignment = Alignment.CenterVertically,',
769
+ ' ) {',
770
+ ' Checkbox(checked = task.done, onCheckedChange = { viewModel.toggle(task.id) })',
771
+ ' Text(task.title)',
772
+ ' }',
773
+ ' }',
774
+ ' }',
775
+ ' }',
776
+ ' }',
777
+ ' }',
778
+ '}',
779
+ '',
780
+ ].join('\n');
781
+ write(`${base}/ui/screens/HomeScreen.kt`, addKotlinImports(screen, [
782
+ 'androidx.compose.foundation.clickable',
783
+ 'androidx.compose.foundation.layout.Box',
784
+ 'androidx.compose.foundation.layout.Column',
785
+ 'androidx.compose.foundation.layout.Row',
786
+ 'androidx.compose.foundation.layout.fillMaxSize',
787
+ 'androidx.compose.foundation.layout.fillMaxWidth',
788
+ 'androidx.compose.foundation.layout.padding',
789
+ 'androidx.compose.foundation.lazy.LazyColumn',
790
+ 'androidx.compose.foundation.lazy.items',
791
+ 'androidx.compose.material3.Button',
792
+ 'androidx.compose.material3.Checkbox',
793
+ 'androidx.compose.material3.CircularProgressIndicator',
794
+ 'androidx.compose.material3.ExperimentalMaterial3Api',
795
+ 'androidx.compose.material3.MaterialTheme',
796
+ 'androidx.compose.material3.Scaffold',
797
+ 'androidx.compose.material3.Text',
798
+ 'androidx.compose.material3.TopAppBar',
799
+ 'androidx.compose.runtime.Composable',
800
+ 'androidx.compose.runtime.collectAsState',
801
+ 'androidx.compose.runtime.getValue',
802
+ 'androidx.compose.ui.Alignment',
803
+ 'androidx.compose.ui.Modifier',
804
+ 'androidx.compose.ui.unit.dp',
805
+ `${pkg}.ui.UiState`,
806
+ hilt ? 'androidx.hilt.navigation.compose.hiltViewModel' : koin ? 'org.koin.androidx.compose.koinViewModel' : 'androidx.lifecycle.viewmodel.compose.viewModel',
807
+ ], `${pkg}.ui.screens`));
808
+ if (koin) {
809
+ const di = `${base}/di/NetworkModule.kt`;
810
+ const text = read(di);
811
+ const m = text ? /\bmodule\s*\{/.exec(text) : null;
812
+ if (text && m) {
813
+ const close = matchBrace(text, m.index + m[0].length - 1, 'dart');
814
+ let t = `${text.slice(0, close).replace(/\s*$/, '')}\n singleOf(::DemoTaskSource)\n viewModelOf(::HomeViewModel)\n${text.slice(close)}`;
815
+ t = addKotlinImports(t, ['org.koin.core.module.dsl.singleOf', 'org.koin.core.module.dsl.viewModelOf', `${pkg}.data.demo.DemoTaskSource`, `${pkg}.ui.screens.HomeViewModel`], `${pkg}.di`);
816
+ write(di, t);
817
+ }
818
+ }
819
+ plan.summary.push(`First screen: a demo task list with HomeViewModel (${hilt ? 'Hilt' : koin ? 'Koin' : 'its own Factory'}) and a StateFlow, on sample data (no network)`);
820
+ return plan;
821
+ }
822
+ // ---------------------------------------------------------------- React (Vite)
823
+ function reactDemo(d, read, write, plan) {
824
+ const page = 'src/pages/HomePage.tsx';
825
+ if (read(page) === null)
826
+ return null;
827
+ const query = d.architecture === 'axios-react-query';
828
+ const q = "'";
829
+ const semi = /;\s*$/m.test(read(page) ?? '') ? ';' : '';
830
+ const st = (line) => line.replace(/;$/, semi);
831
+ write('src/demo/demoTasks.ts', [
832
+ 'export interface DemoTask {',
833
+ st(' id: number;'),
834
+ st(' title: string;'),
835
+ st(' done: boolean;'),
836
+ '}',
837
+ '',
838
+ '/**',
839
+ ' * Stands in for an API: returns sample tasks after a short delay.',
840
+ ` * Replace it with real endpoints (codz api add, or codz feature add <name> --spec api.yaml), for example ${d.architecture === 'fetch-helper' ? "apiFetch<DemoTask[]>('/tasks')" : "(await api.get<DemoTask[]>('/tasks')).data"}.`,
841
+ ' */',
842
+ 'export async function getDemoTasks(): Promise<DemoTask[]> {',
843
+ st(' await new Promise((resolve) => setTimeout(resolve, 300));'),
844
+ ' return [',
845
+ ...TASKS.map((t) => ` { id: ${t.id}, title: ${q}${t.title}${q}, done: ${t.done} },`),
846
+ st(' ];'),
847
+ '}',
848
+ '',
849
+ ].join('\n'));
850
+ const intro = query ? 'TanStack Query loads the tasks (useQuery); ticking one is local state.' : 'The page loads the tasks in an effect and keeps them in state.';
851
+ const list = (items, checked, onChange) => [
852
+ ` <p>${intro}</p>`,
853
+ " <ul style={{ listStyle: 'none', padding: 0 }}>",
854
+ ` {${items}.map((task) => (`,
855
+ ' <li key={task.id}>',
856
+ ' <label>',
857
+ ` <input type="checkbox" checked={${checked}} onChange={() => ${onChange}} /> {task.title}`,
858
+ ' </label>',
859
+ ' </li>',
860
+ ' ))}',
861
+ ' </ul>',
862
+ ];
863
+ const body = query
864
+ ? [
865
+ st("import { useQuery } from '@tanstack/react-query';"),
866
+ st("import { useState } from 'react';"),
867
+ st("import { getDemoTasks } from '../demo/demoTasks';"),
868
+ '',
869
+ 'export default function HomePage() {',
870
+ st(" const { data, isLoading, error } = useQuery({ queryKey: ['demo-tasks'], queryFn: getDemoTasks });"),
871
+ st(' const [flipped, setFlipped] = useState<Record<number, boolean>>({});'),
872
+ st(' if (isLoading) return <p>Loading…</p>;'),
873
+ st(' if (error || !data) return <p role="alert">{error instanceof Error ? error.message : String(error)}</p>;'),
874
+ ' return (',
875
+ ' <main>',
876
+ ` <h1>Home</h1>`,
877
+ ...list('data', 'flipped[task.id] ? !task.done : task.done', 'setFlipped((f) => ({ ...f, [task.id]: !f[task.id] }))'),
878
+ ' </main>',
879
+ st(' );'),
880
+ '}',
881
+ '',
882
+ ]
883
+ : [
884
+ st("import { useEffect, useState } from 'react';"),
885
+ st("import { getDemoTasks, type DemoTask } from '../demo/demoTasks';"),
886
+ '',
887
+ 'export default function HomePage() {',
888
+ st(' const [tasks, setTasks] = useState<DemoTask[] | null>(null);'),
889
+ '',
890
+ ' useEffect(() => {',
891
+ st(' getDemoTasks().then(setTasks);'),
892
+ st(' }, []);'),
893
+ '',
894
+ st(' const toggle = (id: number) => setTasks((ts) => ts && ts.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));'),
895
+ st(' if (!tasks) return <p>Loading…</p>;'),
896
+ ' return (',
897
+ ' <main>',
898
+ ` <h1>Home</h1>`,
899
+ ...list('tasks', 'task.done', 'toggle(task.id)'),
900
+ ' </main>',
901
+ st(' );'),
902
+ '}',
903
+ '',
904
+ ];
905
+ write(page, body.join('\n'));
906
+ plan.summary.push(`First screen: a demo task list ${query ? 'loaded with TanStack Query (useQuery)' : 'loaded in an effect and kept in state'} on the Home page, on sample data (no network)`);
907
+ return plan;
908
+ }
909
+ // ---------------------------------------------------------------- iOS (SwiftUI)
910
+ function iosDemo(d, read, write, plan) {
911
+ const app = d.iosApp;
912
+ if (!app || d.architecture !== 'urlsession')
913
+ return null;
914
+ const appFile = `${app}/${app}App.swift`;
915
+ if (read(appFile) === null)
916
+ return null;
917
+ const views = `${app}/Views`;
918
+ if (read(`${views}/LoadState.swift`) === null) {
919
+ write(`${views}/LoadState.swift`, ['/// What a screen shows while it loads, when it has the data, and when loading failed.', 'enum LoadState<Value> {', ' case loading', ' case loaded(Value)', ' case failed(String)', '}', ''].join('\n'));
920
+ }
921
+ write(`${app}/Demo/DemoTaskSource.swift`, [
922
+ 'import Foundation',
923
+ '',
924
+ '/// One row of the demo list.',
925
+ 'struct DemoTask: Identifiable, Hashable {',
926
+ ' let id: Int',
927
+ ' let title: String',
928
+ ' var done: Bool',
929
+ '}',
930
+ '',
931
+ '/// Stands in for an API: returns sample tasks after a short delay.',
932
+ '/// Replace it with real endpoints: codz api add, or codz feature add <name> --spec api.yaml.',
933
+ 'struct DemoTaskSource {',
934
+ ' func load() async throws -> [DemoTask] {',
935
+ ' try await Task.sleep(nanoseconds: 400_000_000)',
936
+ ' return [',
937
+ ...TASKS.map((t) => ` DemoTask(id: ${t.id}, title: "${t.title}", done: ${t.done}),`),
938
+ ' ]',
939
+ ' }',
940
+ '}',
941
+ '',
942
+ ].join('\n'));
943
+ write(`${views}/HomeViewModel.swift`, [
944
+ 'import Foundation',
945
+ '',
946
+ '@MainActor',
947
+ 'final class HomeViewModel: ObservableObject {',
948
+ ' @Published private(set) var state: LoadState<[DemoTask]> = .loading',
949
+ ' private let source: DemoTaskSource',
950
+ '',
951
+ ' init(source: DemoTaskSource = DemoTaskSource()) {',
952
+ ' self.source = source',
953
+ ' }',
954
+ '',
955
+ ' func load() async {',
956
+ ' do {',
957
+ ' state = .loaded(try await source.load())',
958
+ ' } catch {',
959
+ ' state = .failed(error.localizedDescription)',
960
+ ' }',
961
+ ' }',
962
+ '',
963
+ ' func toggle(_ task: DemoTask) {',
964
+ ' guard case .loaded(var tasks) = state, let i = tasks.firstIndex(of: task) else { return }',
965
+ ' tasks[i].done.toggle()',
966
+ ' state = .loaded(tasks)',
967
+ ' }',
968
+ '}',
969
+ '',
970
+ ].join('\n'));
971
+ write(`${views}/HomeView.swift`, [
972
+ 'import SwiftUI',
973
+ '',
974
+ 'struct HomeView: View {',
975
+ ' @StateObject private var model = HomeViewModel()',
976
+ '',
977
+ ' var body: some View {',
978
+ ' content',
979
+ ` .navigationTitle("${app}")`,
980
+ ' .task { await model.load() }',
981
+ ' .refreshable { await model.load() }',
982
+ ' }',
983
+ '',
984
+ ' @ViewBuilder',
985
+ ' private var content: some View {',
986
+ ' switch model.state {',
987
+ ' case .loading:',
988
+ ' ProgressView()',
989
+ ' case .failed(let message):',
990
+ ' VStack(spacing: 12) {',
991
+ ' Text(message)',
992
+ ' Button("Retry") {',
993
+ ' Task { await model.load() }',
994
+ ' }',
995
+ ' }',
996
+ ' .padding()',
997
+ ' case .loaded(let tasks):',
998
+ ' List {',
999
+ ' Section {',
1000
+ ' ForEach(tasks) { task in',
1001
+ ' Button {',
1002
+ ' model.toggle(task)',
1003
+ ' } label: {',
1004
+ ' Label(task.title, systemImage: task.done ? "checkmark.circle.fill" : "circle")',
1005
+ ' }',
1006
+ ' }',
1007
+ ' } footer: {',
1008
+ ' Text("HomeViewModel (an ObservableObject) holds the list; the view observes it.")',
1009
+ ' }',
1010
+ ' }',
1011
+ ' }',
1012
+ ' }',
1013
+ '}',
1014
+ '',
1015
+ '#Preview {',
1016
+ ' NavigationStack {',
1017
+ ' HomeView()',
1018
+ ' }',
1019
+ '}',
1020
+ '',
1021
+ ].join('\n'));
1022
+ write(appFile, `import SwiftUI\n\n@main\nstruct ${app}App: App {\n var body: some Scene {\n WindowGroup {\n NavigationStack {\n HomeView()\n }\n }\n }\n}\n`);
1023
+ plan.deletes = [`${app}/ContentView.swift`];
1024
+ plan.summary.push('First screen: a demo task list with HomeViewModel (an ObservableObject) inside a NavigationStack, on sample data (no network)');
1025
+ return plan;
1026
+ }