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.
- checksums.yaml +4 -4
- data/README.md +101 -47
- data/lib/whoosh/app.rb +3 -2
- data/lib/whoosh/cli/client_generator.rb +237 -0
- data/lib/whoosh/cli/main.rb +10 -0
- data/lib/whoosh/client_gen/base_generator.rb +84 -0
- data/lib/whoosh/client_gen/dependency_checker.rb +49 -0
- data/lib/whoosh/client_gen/fallback_backend.rb +292 -0
- data/lib/whoosh/client_gen/generators/expo.rb +1038 -0
- data/lib/whoosh/client_gen/generators/flutter.rb +915 -0
- data/lib/whoosh/client_gen/generators/htmx.rb +498 -0
- data/lib/whoosh/client_gen/generators/ios.rb +832 -0
- data/lib/whoosh/client_gen/generators/react_spa.rb +932 -0
- data/lib/whoosh/client_gen/generators/telegram_bot.rb +624 -0
- data/lib/whoosh/client_gen/generators/telegram_mini_app.rb +844 -0
- data/lib/whoosh/client_gen/introspector.rb +178 -0
- data/lib/whoosh/client_gen/ir.rb +37 -0
- data/lib/whoosh/version.rb +1 -1
- metadata +14 -1
|
@@ -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
|