whoosh 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,915 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "whoosh/client_gen/base_generator"
4
+
5
+ module Whoosh
6
+ module ClientGen
7
+ module Generators
8
+ class Flutter < BaseGenerator
9
+ def generate
10
+ generate_pubspec
11
+ generate_main
12
+ generate_api_client
13
+ generate_router
14
+
15
+ if ir.has_auth?
16
+ generate_auth_service
17
+ generate_auth_provider
18
+ generate_auth_screens
19
+ end
20
+
21
+ ir.resources.each do |resource|
22
+ generate_model(resource)
23
+ generate_resource_service(resource)
24
+ generate_resource_provider(resource)
25
+ generate_resource_screens(resource)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # ── pubspec.yaml ──────────────────────────────────────────────
32
+
33
+ def generate_pubspec
34
+ write_file("pubspec.yaml", <<~YAML)
35
+ name: whoosh_app
36
+ description: Generated Flutter app by Whoosh
37
+ version: 1.0.0+1
38
+
39
+ environment:
40
+ sdk: ">=3.0.0 <4.0.0"
41
+ flutter: ">=3.10.0"
42
+
43
+ dependencies:
44
+ flutter:
45
+ sdk: flutter
46
+ dio: ^5.4.0
47
+ flutter_riverpod: ^2.5.1
48
+ go_router: ^13.2.0
49
+ flutter_secure_storage: ^9.0.0
50
+
51
+ dev_dependencies:
52
+ flutter_test:
53
+ sdk: flutter
54
+ flutter_lints: ^3.0.0
55
+
56
+ flutter:
57
+ uses-material-design: true
58
+ YAML
59
+ end
60
+
61
+ # ── main.dart ─────────────────────────────────────────────────
62
+
63
+ def generate_main
64
+ first_resource = ir.resources.first
65
+ home_route = first_resource ? "/#{first_resource.name}" : "/"
66
+
67
+ write_file("lib/main.dart", <<~DART)
68
+ import 'package:flutter/material.dart';
69
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
70
+ import 'router.dart';
71
+
72
+ void main() {
73
+ runApp(const ProviderScope(child: WhooshApp()));
74
+ }
75
+
76
+ class WhooshApp extends ConsumerWidget {
77
+ const WhooshApp({super.key});
78
+
79
+ @override
80
+ Widget build(BuildContext context, WidgetRef ref) {
81
+ final router = ref.watch(routerProvider);
82
+ return MaterialApp.router(
83
+ title: 'Whoosh App',
84
+ theme: ThemeData(
85
+ colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
86
+ useMaterial3: true,
87
+ ),
88
+ routerConfig: router,
89
+ );
90
+ }
91
+ }
92
+ DART
93
+ end
94
+
95
+ # ── API Client ────────────────────────────────────────────────
96
+
97
+ def generate_api_client
98
+ write_file("lib/api/client.dart", <<~DART)
99
+ import 'package:dio/dio.dart';
100
+ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
101
+
102
+ const String _baseUrl = '#{ir.base_url}';
103
+ const _storage = FlutterSecureStorage();
104
+
105
+ Dio createDioClient() {
106
+ final dio = Dio(BaseOptions(baseUrl: _baseUrl));
107
+ dio.interceptors.add(AuthInterceptor(dio));
108
+ return dio;
109
+ }
110
+
111
+ final dio = createDioClient();
112
+
113
+ class AuthInterceptor extends Interceptor {
114
+ final Dio _dio;
115
+
116
+ AuthInterceptor(this._dio);
117
+
118
+ @override
119
+ Future<void> onRequest(
120
+ RequestOptions options,
121
+ RequestInterceptorHandler handler,
122
+ ) async {
123
+ final token = await _storage.read(key: 'auth_token');
124
+ if (token != null) {
125
+ options.headers['Authorization'] = 'Bearer $token';
126
+ }
127
+ handler.next(options);
128
+ }
129
+
130
+ @override
131
+ void onError(DioException err, ErrorInterceptorHandler handler) {
132
+ handler.next(err);
133
+ }
134
+ }
135
+ DART
136
+ end
137
+
138
+ # ── Auth Service ──────────────────────────────────────────────
139
+
140
+ def generate_auth_service
141
+ write_file("lib/api/auth_service.dart", <<~DART)
142
+ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
143
+ import 'client.dart';
144
+
145
+ const _storage = FlutterSecureStorage();
146
+
147
+ class AuthService {
148
+ static Future<Map<String, dynamic>> login(String email, String password) async {
149
+ final response = await dio.post('/auth/login', data: {
150
+ 'email': email,
151
+ 'password': password,
152
+ });
153
+ final token = response.data['token'] as String?;
154
+ if (token != null) {
155
+ await _storage.write(key: 'auth_token', value: token);
156
+ }
157
+ return Map<String, dynamic>.from(response.data);
158
+ }
159
+
160
+ static Future<Map<String, dynamic>> register(String email, String password) async {
161
+ final response = await dio.post('/auth/register', data: {
162
+ 'email': email,
163
+ 'password': password,
164
+ });
165
+ final token = response.data['token'] as String?;
166
+ if (token != null) {
167
+ await _storage.write(key: 'auth_token', value: token);
168
+ }
169
+ return Map<String, dynamic>.from(response.data);
170
+ }
171
+
172
+ static Future<void> logout() async {
173
+ await dio.delete('/auth/logout');
174
+ await _storage.delete(key: 'auth_token');
175
+ }
176
+
177
+ static Future<bool> isLoggedIn() async {
178
+ final token = await _storage.read(key: 'auth_token');
179
+ return token != null;
180
+ }
181
+ }
182
+ DART
183
+ end
184
+
185
+ # ── Auth Provider ─────────────────────────────────────────────
186
+
187
+ def generate_auth_provider
188
+ write_file("lib/providers/auth_provider.dart", <<~DART)
189
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
190
+ import '../api/auth_service.dart';
191
+
192
+ class AuthState {
193
+ final bool isAuthenticated;
194
+ final bool isLoading;
195
+ final String? error;
196
+
197
+ const AuthState({
198
+ this.isAuthenticated = false,
199
+ this.isLoading = false,
200
+ this.error,
201
+ });
202
+
203
+ AuthState copyWith({
204
+ bool? isAuthenticated,
205
+ bool? isLoading,
206
+ String? error,
207
+ }) {
208
+ return AuthState(
209
+ isAuthenticated: isAuthenticated ?? this.isAuthenticated,
210
+ isLoading: isLoading ?? this.isLoading,
211
+ error: error,
212
+ );
213
+ }
214
+ }
215
+
216
+ class AuthNotifier extends StateNotifier<AuthState> {
217
+ AuthNotifier() : super(const AuthState()) {
218
+ _checkAuth();
219
+ }
220
+
221
+ Future<void> _checkAuth() async {
222
+ final loggedIn = await AuthService.isLoggedIn();
223
+ state = state.copyWith(isAuthenticated: loggedIn);
224
+ }
225
+
226
+ Future<void> login(String email, String password) async {
227
+ state = state.copyWith(isLoading: true);
228
+ try {
229
+ await AuthService.login(email, password);
230
+ state = state.copyWith(isAuthenticated: true, isLoading: false);
231
+ } catch (e) {
232
+ state = state.copyWith(isLoading: false, error: e.toString());
233
+ }
234
+ }
235
+
236
+ Future<void> register(String email, String password) async {
237
+ state = state.copyWith(isLoading: true);
238
+ try {
239
+ await AuthService.register(email, password);
240
+ state = state.copyWith(isAuthenticated: true, isLoading: false);
241
+ } catch (e) {
242
+ state = state.copyWith(isLoading: false, error: e.toString());
243
+ }
244
+ }
245
+
246
+ Future<void> logout() async {
247
+ await AuthService.logout();
248
+ state = state.copyWith(isAuthenticated: false);
249
+ }
250
+ }
251
+
252
+ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
253
+ (ref) => AuthNotifier(),
254
+ );
255
+ DART
256
+ end
257
+
258
+ # ── Auth Screens ──────────────────────────────────────────────
259
+
260
+ def generate_auth_screens
261
+ generate_login_screen
262
+ generate_register_screen
263
+ end
264
+
265
+ def generate_login_screen
266
+ write_file("lib/screens/auth/login_screen.dart", <<~DART)
267
+ import 'package:flutter/material.dart';
268
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
269
+ import 'package:go_router/go_router.dart';
270
+ import '../../providers/auth_provider.dart';
271
+
272
+ class LoginScreen extends ConsumerStatefulWidget {
273
+ const LoginScreen({super.key});
274
+
275
+ @override
276
+ ConsumerState<LoginScreen> createState() => _LoginScreenState();
277
+ }
278
+
279
+ class _LoginScreenState extends ConsumerState<LoginScreen> {
280
+ final _formKey = GlobalKey<FormState>();
281
+ final _emailController = TextEditingController();
282
+ final _passwordController = TextEditingController();
283
+
284
+ @override
285
+ void dispose() {
286
+ _emailController.dispose();
287
+ _passwordController.dispose();
288
+ super.dispose();
289
+ }
290
+
291
+ Future<void> _submit() async {
292
+ if (!_formKey.currentState!.validate()) return;
293
+ await ref.read(authProvider.notifier).login(
294
+ _emailController.text,
295
+ _passwordController.text,
296
+ );
297
+ }
298
+
299
+ @override
300
+ Widget build(BuildContext context) {
301
+ final auth = ref.watch(authProvider);
302
+ return Scaffold(
303
+ appBar: AppBar(title: const Text('Login')),
304
+ body: Padding(
305
+ padding: const EdgeInsets.all(16.0),
306
+ child: Form(
307
+ key: _formKey,
308
+ child: Column(
309
+ children: [
310
+ TextFormField(
311
+ controller: _emailController,
312
+ decoration: const InputDecoration(labelText: 'Email'),
313
+ keyboardType: TextInputType.emailAddress,
314
+ validator: (v) => v!.isEmpty ? 'Required' : null,
315
+ ),
316
+ TextFormField(
317
+ controller: _passwordController,
318
+ decoration: const InputDecoration(labelText: 'Password'),
319
+ obscureText: true,
320
+ validator: (v) => v!.isEmpty ? 'Required' : null,
321
+ ),
322
+ if (auth.error != null)
323
+ Text(auth.error!, style: const TextStyle(color: Colors.red)),
324
+ const SizedBox(height: 16),
325
+ auth.isLoading
326
+ ? const CircularProgressIndicator()
327
+ : ElevatedButton(
328
+ onPressed: _submit,
329
+ child: const Text('Login'),
330
+ ),
331
+ TextButton(
332
+ onPressed: () => context.go('/register'),
333
+ child: const Text("Don't have an account? Register"),
334
+ ),
335
+ ],
336
+ ),
337
+ ),
338
+ ),
339
+ );
340
+ }
341
+ }
342
+ DART
343
+ end
344
+
345
+ def generate_register_screen
346
+ write_file("lib/screens/auth/register_screen.dart", <<~DART)
347
+ import 'package:flutter/material.dart';
348
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
349
+ import 'package:go_router/go_router.dart';
350
+ import '../../providers/auth_provider.dart';
351
+
352
+ class RegisterScreen extends ConsumerStatefulWidget {
353
+ const RegisterScreen({super.key});
354
+
355
+ @override
356
+ ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
357
+ }
358
+
359
+ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
360
+ final _formKey = GlobalKey<FormState>();
361
+ final _emailController = TextEditingController();
362
+ final _passwordController = TextEditingController();
363
+
364
+ @override
365
+ void dispose() {
366
+ _emailController.dispose();
367
+ _passwordController.dispose();
368
+ super.dispose();
369
+ }
370
+
371
+ Future<void> _submit() async {
372
+ if (!_formKey.currentState!.validate()) return;
373
+ await ref.read(authProvider.notifier).register(
374
+ _emailController.text,
375
+ _passwordController.text,
376
+ );
377
+ }
378
+
379
+ @override
380
+ Widget build(BuildContext context) {
381
+ final auth = ref.watch(authProvider);
382
+ return Scaffold(
383
+ appBar: AppBar(title: const Text('Register')),
384
+ body: Padding(
385
+ padding: const EdgeInsets.all(16.0),
386
+ child: Form(
387
+ key: _formKey,
388
+ child: Column(
389
+ children: [
390
+ TextFormField(
391
+ controller: _emailController,
392
+ decoration: const InputDecoration(labelText: 'Email'),
393
+ keyboardType: TextInputType.emailAddress,
394
+ validator: (v) => v!.isEmpty ? 'Required' : null,
395
+ ),
396
+ TextFormField(
397
+ controller: _passwordController,
398
+ decoration: const InputDecoration(labelText: 'Password'),
399
+ obscureText: true,
400
+ validator: (v) => v!.isEmpty ? 'Required' : null,
401
+ ),
402
+ if (auth.error != null)
403
+ Text(auth.error!, style: const TextStyle(color: Colors.red)),
404
+ const SizedBox(height: 16),
405
+ auth.isLoading
406
+ ? const CircularProgressIndicator()
407
+ : ElevatedButton(
408
+ onPressed: _submit,
409
+ child: const Text('Register'),
410
+ ),
411
+ TextButton(
412
+ onPressed: () => context.go('/login'),
413
+ child: const Text('Already have an account? Login'),
414
+ ),
415
+ ],
416
+ ),
417
+ ),
418
+ ),
419
+ );
420
+ }
421
+ }
422
+ DART
423
+ end
424
+
425
+ # ── Models ────────────────────────────────────────────────────
426
+
427
+ def generate_model(resource)
428
+ name = classify(resource.name)
429
+ singular = singularize(resource.name.to_s)
430
+ fields = resource.fields || []
431
+
432
+ field_declarations = fields.map do |f|
433
+ fname = f[:name].to_s
434
+ ftype = type_for(f[:type])
435
+ ftype = "#{ftype}?" unless f[:required]
436
+ " final #{ftype} #{fname};"
437
+ end.join("\n")
438
+
439
+ constructor_params = fields.map do |f|
440
+ fname = f[:name].to_s
441
+ if f[:required]
442
+ " required this.#{fname},"
443
+ else
444
+ " this.#{fname},"
445
+ end
446
+ end.join("\n")
447
+
448
+ from_json_fields = fields.map do |f|
449
+ fname = f[:name].to_s
450
+ ftype = type_for(f[:type])
451
+ if f[:required]
452
+ " #{fname}: json['#{fname}'] as #{ftype},"
453
+ else
454
+ " #{fname}: json['#{fname}'] as #{ftype}?,"
455
+ end
456
+ end.join("\n")
457
+
458
+ to_json_fields = fields.map do |f|
459
+ fname = f[:name].to_s
460
+ " '#{fname}': #{fname},"
461
+ end.join("\n")
462
+
463
+ write_file("lib/models/#{singular}.dart", <<~DART)
464
+ class #{name} {
465
+ final int? id;
466
+ #{field_declarations}
467
+
468
+ const #{name}({
469
+ this.id,
470
+ #{constructor_params}
471
+ });
472
+
473
+ factory #{name}.fromJson(Map<String, dynamic> json) {
474
+ return #{name}(
475
+ id: json['id'] as int?,
476
+ #{from_json_fields}
477
+ );
478
+ }
479
+
480
+ Map<String, dynamic> toJson() {
481
+ return {
482
+ if (id != null) 'id': id,
483
+ #{to_json_fields}
484
+ };
485
+ }
486
+ }
487
+ DART
488
+ end
489
+
490
+ # ── Resource Service ──────────────────────────────────────────
491
+
492
+ def generate_resource_service(resource)
493
+ name = classify(resource.name)
494
+ plural = resource.name.to_s
495
+ singular = singularize(plural)
496
+
497
+ write_file("lib/api/#{singular}_service.dart", <<~DART)
498
+ import '../models/#{singular}.dart';
499
+ import 'client.dart';
500
+
501
+ class #{name}Service {
502
+ static Future<List<#{name}>> list() async {
503
+ final response = await dio.get('/#{plural}');
504
+ return (response.data as List)
505
+ .map((e) => #{name}.fromJson(Map<String, dynamic>.from(e)))
506
+ .toList();
507
+ }
508
+
509
+ static Future<#{name}> get(int id) async {
510
+ final response = await dio.get('/#{plural}/$id');
511
+ return #{name}.fromJson(Map<String, dynamic>.from(response.data));
512
+ }
513
+
514
+ static Future<#{name}> create(#{name} item) async {
515
+ final response = await dio.post('/#{plural}', data: item.toJson());
516
+ return #{name}.fromJson(Map<String, dynamic>.from(response.data));
517
+ }
518
+
519
+ static Future<#{name}> update(int id, #{name} item) async {
520
+ final response = await dio.put('/#{plural}/$id', data: item.toJson());
521
+ return #{name}.fromJson(Map<String, dynamic>.from(response.data));
522
+ }
523
+
524
+ static Future<void> delete(int id) async {
525
+ await dio.delete('/#{plural}/$id');
526
+ }
527
+ }
528
+ DART
529
+ end
530
+
531
+ # ── Resource Provider ─────────────────────────────────────────
532
+
533
+ def generate_resource_provider(resource)
534
+ name = classify(resource.name)
535
+ plural = resource.name.to_s
536
+ singular = singularize(plural)
537
+
538
+ write_file("lib/providers/#{singular}_provider.dart", <<~DART)
539
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
540
+ import '../models/#{singular}.dart';
541
+ import '../api/#{singular}_service.dart';
542
+
543
+ class #{name}Notifier extends StateNotifier<AsyncValue<List<#{name}>>> {
544
+ #{name}Notifier() : super(const AsyncValue.loading()) {
545
+ fetchAll();
546
+ }
547
+
548
+ Future<void> fetchAll() async {
549
+ state = const AsyncValue.loading();
550
+ state = await AsyncValue.guard(() => #{name}Service.list());
551
+ }
552
+
553
+ Future<void> create(#{name} item) async {
554
+ final created = await #{name}Service.create(item);
555
+ state.whenData((items) {
556
+ state = AsyncValue.data([...items, created]);
557
+ });
558
+ }
559
+
560
+ Future<void> update(int id, #{name} item) async {
561
+ final updated = await #{name}Service.update(id, item);
562
+ state.whenData((items) {
563
+ state = AsyncValue.data(
564
+ items.map((i) => i.id == id ? updated : i).toList(),
565
+ );
566
+ });
567
+ }
568
+
569
+ Future<void> delete(int id) async {
570
+ await #{name}Service.delete(id);
571
+ state.whenData((items) {
572
+ state = AsyncValue.data(items.where((i) => i.id != id).toList());
573
+ });
574
+ }
575
+ }
576
+
577
+ final #{singular}Provider = StateNotifierProvider<#{name}Notifier, AsyncValue<List<#{name}>>>(
578
+ (ref) => #{name}Notifier(),
579
+ );
580
+ DART
581
+ end
582
+
583
+ # ── Resource Screens ──────────────────────────────────────────
584
+
585
+ def generate_resource_screens(resource)
586
+ name = classify(resource.name)
587
+ plural = resource.name.to_s
588
+ singular = singularize(plural)
589
+ fields = resource.fields || []
590
+
591
+ generate_list_screen(resource, name, plural, singular, fields)
592
+ generate_detail_screen(resource, name, plural, singular, fields)
593
+ generate_form_screen(resource, name, plural, singular, fields)
594
+ end
595
+
596
+ def generate_list_screen(resource, name, plural, singular, fields)
597
+ display_field = fields.find { |f| f[:required] } || fields.first
598
+ display_expr = display_field ? "item.#{display_field[:name]}.toString()" : "item.id.toString()"
599
+
600
+ write_file("lib/screens/#{plural}/#{singular}_list_screen.dart", <<~DART)
601
+ import 'package:flutter/material.dart';
602
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
603
+ import 'package:go_router/go_router.dart';
604
+ import '../../providers/#{singular}_provider.dart';
605
+
606
+ class #{name}ListScreen extends ConsumerWidget {
607
+ const #{name}ListScreen({super.key});
608
+
609
+ @override
610
+ Widget build(BuildContext context, WidgetRef ref) {
611
+ final state = ref.watch(#{singular}Provider);
612
+ return Scaffold(
613
+ appBar: AppBar(
614
+ title: const Text('#{name}s'),
615
+ actions: [
616
+ IconButton(
617
+ icon: const Icon(Icons.add),
618
+ onPressed: () => context.go('/#{plural}/new'),
619
+ ),
620
+ ],
621
+ ),
622
+ body: state.when(
623
+ loading: () => const Center(child: CircularProgressIndicator()),
624
+ error: (e, _) => Center(child: Text('Error: $e')),
625
+ data: (items) => RefreshIndicator(
626
+ onRefresh: () => ref.read(#{singular}Provider.notifier).fetchAll(),
627
+ child: ListView.builder(
628
+ itemCount: items.length,
629
+ itemBuilder: (context, index) {
630
+ final item = items[index];
631
+ return ListTile(
632
+ title: Text(#{display_expr}),
633
+ onTap: () => context.go('/#{plural}/${item.id}'),
634
+ trailing: IconButton(
635
+ icon: const Icon(Icons.delete),
636
+ onPressed: () => ref
637
+ .read(#{singular}Provider.notifier)
638
+ .delete(item.id!),
639
+ ),
640
+ );
641
+ },
642
+ ),
643
+ ),
644
+ ),
645
+ );
646
+ }
647
+ }
648
+ DART
649
+ end
650
+
651
+ def generate_detail_screen(resource, name, plural, singular, fields)
652
+ field_rows = fields.map do |f|
653
+ fname = f[:name].to_s
654
+ label = fname.capitalize
655
+ " ListTile(title: Text('#{label}'), subtitle: Text(item.#{fname}?.toString() ?? '')),"
656
+ end.join("\n")
657
+
658
+ write_file("lib/screens/#{plural}/#{singular}_detail_screen.dart", <<~DART)
659
+ import 'package:flutter/material.dart';
660
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
661
+ import 'package:go_router/go_router.dart';
662
+ import '../../providers/#{singular}_provider.dart';
663
+
664
+ class #{name}DetailScreen extends ConsumerWidget {
665
+ final int id;
666
+
667
+ const #{name}DetailScreen({super.key, required this.id});
668
+
669
+ @override
670
+ Widget build(BuildContext context, WidgetRef ref) {
671
+ final state = ref.watch(#{singular}Provider);
672
+ return Scaffold(
673
+ appBar: AppBar(
674
+ title: const Text('#{name} Detail'),
675
+ actions: [
676
+ IconButton(
677
+ icon: const Icon(Icons.edit),
678
+ onPressed: () => context.go('/#{plural}/$id/edit'),
679
+ ),
680
+ IconButton(
681
+ icon: const Icon(Icons.delete),
682
+ onPressed: () async {
683
+ await ref.read(#{singular}Provider.notifier).delete(id);
684
+ if (context.mounted) context.go('/#{plural}');
685
+ },
686
+ ),
687
+ ],
688
+ ),
689
+ body: state.when(
690
+ loading: () => const Center(child: CircularProgressIndicator()),
691
+ error: (e, _) => Center(child: Text('Error: $e')),
692
+ data: (items) {
693
+ final item = items.where((i) => i.id == id).firstOrNull;
694
+ if (item == null) return const Center(child: Text('Not found'));
695
+ return ListView(
696
+ children: [
697
+ #{field_rows}
698
+ ],
699
+ );
700
+ },
701
+ ),
702
+ );
703
+ }
704
+ }
705
+ DART
706
+ end
707
+
708
+ def generate_form_screen(resource, name, plural, singular, fields)
709
+ controller_decls = fields.map do |f|
710
+ fname = f[:name].to_s
711
+ " final _#{fname}Controller = TextEditingController();"
712
+ end.join("\n")
713
+
714
+ dispose_calls = fields.map do |f|
715
+ " _#{f[:name]}Controller.dispose();"
716
+ end.join("\n")
717
+
718
+ form_fields = fields.map do |f|
719
+ fname = f[:name].to_s
720
+ label = fname.capitalize
721
+ <<~DART.strip
722
+ TextFormField(
723
+ controller: _#{fname}Controller,
724
+ decoration: const InputDecoration(labelText: '#{label}'),
725
+ validator: (v) => #{f[:required] ? "v!.isEmpty ? 'Required' : null" : "null"},
726
+ ),
727
+ DART
728
+ end.join("\n ")
729
+
730
+ build_item = fields.map do |f|
731
+ fname = f[:name].to_s
732
+ ftype = type_for(f[:type])
733
+ if ftype == "int"
734
+ " #{fname}: int.tryParse(_#{fname}Controller.text),"
735
+ elsif ftype == "double"
736
+ " #{fname}: double.tryParse(_#{fname}Controller.text),"
737
+ elsif ftype == "bool"
738
+ " #{fname}: _#{fname}Controller.text.toLowerCase() == 'true',"
739
+ else
740
+ if f[:required]
741
+ " #{fname}: _#{fname}Controller.text,"
742
+ else
743
+ " #{fname}: _#{fname}Controller.text.isEmpty ? null : _#{fname}Controller.text,"
744
+ end
745
+ end
746
+ end.join("\n")
747
+
748
+ write_file("lib/screens/#{plural}/#{singular}_form_screen.dart", <<~DART)
749
+ import 'package:flutter/material.dart';
750
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
751
+ import 'package:go_router/go_router.dart';
752
+ import '../../models/#{singular}.dart';
753
+ import '../../providers/#{singular}_provider.dart';
754
+
755
+ class #{name}FormScreen extends ConsumerStatefulWidget {
756
+ final int? existingId;
757
+
758
+ const #{name}FormScreen({super.key, this.existingId});
759
+
760
+ @override
761
+ ConsumerState<#{name}FormScreen> createState() => _#{name}FormScreenState();
762
+ }
763
+
764
+ class _#{name}FormScreenState extends ConsumerState<#{name}FormScreen> {
765
+ final _formKey = GlobalKey<FormState>();
766
+ #{controller_decls}
767
+
768
+ @override
769
+ void dispose() {
770
+ #{dispose_calls}
771
+ super.dispose();
772
+ }
773
+
774
+ Future<void> _submit() async {
775
+ if (!_formKey.currentState!.validate()) return;
776
+ final item = #{name}(
777
+ #{build_item}
778
+ );
779
+ final notifier = ref.read(#{singular}Provider.notifier);
780
+ if (widget.existingId != null) {
781
+ await notifier.update(widget.existingId!, item);
782
+ } else {
783
+ await notifier.create(item);
784
+ }
785
+ if (mounted) context.go('/#{plural}');
786
+ }
787
+
788
+ @override
789
+ Widget build(BuildContext context) {
790
+ return Scaffold(
791
+ appBar: AppBar(
792
+ title: Text(widget.existingId == null ? 'New #{name}' : 'Edit #{name}'),
793
+ ),
794
+ body: Padding(
795
+ padding: const EdgeInsets.all(16.0),
796
+ child: Form(
797
+ key: _formKey,
798
+ child: Column(
799
+ children: [
800
+ #{form_fields}
801
+ const SizedBox(height: 16),
802
+ ElevatedButton(
803
+ onPressed: _submit,
804
+ child: Text(widget.existingId == null ? 'Create' : 'Update'),
805
+ ),
806
+ ],
807
+ ),
808
+ ),
809
+ ),
810
+ );
811
+ }
812
+ }
813
+ DART
814
+ end
815
+
816
+ # ── GoRouter ──────────────────────────────────────────────────
817
+
818
+ def generate_router
819
+ resource_imports = ir.resources.map do |r|
820
+ name = classify(r.name)
821
+ plural = r.name.to_s
822
+ singular = singularize(plural)
823
+ [
824
+ "import 'screens/#{plural}/#{singular}_list_screen.dart';",
825
+ "import 'screens/#{plural}/#{singular}_detail_screen.dart';",
826
+ "import 'screens/#{plural}/#{singular}_form_screen.dart';"
827
+ ].join("\n")
828
+ end.join("\n")
829
+
830
+ auth_imports = if ir.has_auth?
831
+ "import 'screens/auth/login_screen.dart';\nimport 'screens/auth/register_screen.dart';\nimport 'providers/auth_provider.dart';"
832
+ else
833
+ ""
834
+ end
835
+
836
+ resource_routes = ir.resources.map do |r|
837
+ name = classify(r.name)
838
+ plural = r.name.to_s
839
+ singular = singularize(plural)
840
+ <<~DART.chomp
841
+ GoRoute(
842
+ path: '/#{plural}',
843
+ builder: (context, state) => const #{name}ListScreen(),
844
+ ),
845
+ GoRoute(
846
+ path: '/#{plural}/new',
847
+ builder: (context, state) => const #{name}FormScreen(),
848
+ ),
849
+ GoRoute(
850
+ path: '/#{plural}/:id',
851
+ builder: (context, state) {
852
+ final id = int.parse(state.pathParameters['id']!);
853
+ return #{name}DetailScreen(id: id);
854
+ },
855
+ ),
856
+ GoRoute(
857
+ path: '/#{plural}/:id/edit',
858
+ builder: (context, state) {
859
+ final id = int.parse(state.pathParameters['id']!);
860
+ return #{name}FormScreen(existingId: id);
861
+ },
862
+ ),
863
+ DART
864
+ end.join("\n")
865
+
866
+ auth_routes = if ir.has_auth?
867
+ <<~DART.chomp
868
+ GoRoute(
869
+ path: '/login',
870
+ builder: (context, state) => const LoginScreen(),
871
+ ),
872
+ GoRoute(
873
+ path: '/register',
874
+ builder: (context, state) => const RegisterScreen(),
875
+ ),
876
+ DART
877
+ else
878
+ ""
879
+ end
880
+
881
+ redirect_logic = if ir.has_auth?
882
+ <<~DART.chomp
883
+ redirect: (context, state) {
884
+ // auth redirect handled by authProvider
885
+ return null;
886
+ },
887
+ DART
888
+ else
889
+ ""
890
+ end
891
+
892
+ first_path = ir.resources.first ? "/#{ir.resources.first.name}" : "/"
893
+
894
+ write_file("lib/router.dart", <<~DART)
895
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
896
+ import 'package:go_router/go_router.dart';
897
+ #{auth_imports}
898
+ #{resource_imports}
899
+
900
+ final routerProvider = Provider<GoRouter>((ref) {
901
+ return GoRouter(
902
+ initialLocation: '#{first_path}',
903
+ #{redirect_logic}
904
+ routes: [
905
+ #{auth_routes}
906
+ #{resource_routes}
907
+ ],
908
+ );
909
+ });
910
+ DART
911
+ end
912
+ end
913
+ end
914
+ end
915
+ end