create-odoo-module 1.0.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,1695 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { writeFile, mkdirp } = require('../utils/file-system');
5
+ const logger = require('../utils/logger');
6
+
7
+ /**
8
+ * Generate a production-ready Flutter/Dart application
9
+ * pre-wired to the Odoo module via JSON-RPC.
10
+ */
11
+ async function generateFlutterApp(targetDir, config) {
12
+ const s = config.moduleNameSnake;
13
+ const P = config.moduleNamePascal;
14
+ const M = config.moduleNameOdoo;
15
+ const L = config.moduleNameLabel;
16
+ const K = s.replace(/_/g, '-'); // kebab for pubspec name
17
+
18
+ logger.verbose('Generating Flutter app...');
19
+
20
+ const flutterDir = path.join(targetDir, 'flutter_app');
21
+
22
+ // ── Directory skeleton ────────────────────────────────────────────────────
23
+ const dirs = [
24
+ 'lib/src/config',
25
+ 'lib/src/core',
26
+ 'lib/src/models',
27
+ 'lib/src/repositories',
28
+ 'lib/src/screens',
29
+ 'lib/src/widgets',
30
+ 'lib/src/providers',
31
+ 'lib/l10n',
32
+ 'test',
33
+ 'integration_test',
34
+ 'assets/images',
35
+ 'assets/icons',
36
+ 'android/app/src/main',
37
+ 'ios/Runner',
38
+ ];
39
+ for (const d of dirs) await mkdirp(path.join(flutterDir, d));
40
+
41
+ // ── Generate all files ────────────────────────────────────────────────────
42
+ await Promise.all([
43
+ writeFile(path.join(flutterDir, 'pubspec.yaml'), genPubspec(s, K, L, config)),
44
+ writeFile(path.join(flutterDir, 'analysis_options.yaml'), genAnalysisOptions()),
45
+ writeFile(path.join(flutterDir, '.gitignore'), genFlutterGitignore()),
46
+ writeFile(path.join(flutterDir, 'README.md'), genFlutterReadme(L, M, s, config)),
47
+
48
+ // lib/
49
+ writeFile(path.join(flutterDir, 'lib', 'main.dart'), genMain(s, P, L, config)),
50
+
51
+ // lib/src/config/
52
+ writeFile(path.join(flutterDir, 'lib', 'src', 'config', 'odoo_config.dart'), genConfig(s, config)),
53
+
54
+ // lib/src/core/
55
+ writeFile(path.join(flutterDir, 'lib', 'src', 'core', 'odoo_client.dart'), genOdooClient(s, P, M)),
56
+ writeFile(path.join(flutterDir, 'lib', 'src', 'core', 'auth_service.dart'), genAuthService(s, P)),
57
+ writeFile(path.join(flutterDir, 'lib', 'src', 'core', 'exceptions.dart'), genExceptions()),
58
+
59
+ // lib/src/models/
60
+ writeFile(path.join(flutterDir, 'lib', 'src', 'models', `${s}_model.dart`), genModel(s, P, M, L)),
61
+
62
+ // lib/src/repositories/
63
+ writeFile(path.join(flutterDir, 'lib', 'src', 'repositories', `${s}_repository.dart`), genRepository(s, P, M, L)),
64
+
65
+ // lib/src/providers/
66
+ writeFile(path.join(flutterDir, 'lib', 'src', 'providers', `${s}_provider.dart`), genProvider(s, P, M, L)),
67
+
68
+ // lib/src/screens/
69
+ writeFile(path.join(flutterDir, 'lib', 'src', 'screens', 'login_screen.dart'), genLoginScreen(s, P, L)),
70
+ writeFile(path.join(flutterDir, 'lib', 'src', 'screens', 'list_screen.dart'), genListScreen(s, P, L)),
71
+ writeFile(path.join(flutterDir, 'lib', 'src', 'screens', 'detail_screen.dart'), genDetailScreen(s, P, M, L)),
72
+ writeFile(path.join(flutterDir, 'lib', 'src', 'screens', 'create_screen.dart'), genCreateScreen(s, P, M, L)),
73
+
74
+ // lib/src/widgets/
75
+ writeFile(path.join(flutterDir, 'lib', 'src', 'widgets', `${s}_card.dart`), genCard(s, P, L)),
76
+ writeFile(path.join(flutterDir, 'lib', 'src', 'widgets', 'state_badge.dart'), genStateBadge()),
77
+ writeFile(path.join(flutterDir, 'lib', 'src', 'widgets', 'loading_overlay.dart'), genLoadingOverlay()),
78
+
79
+ // tests
80
+ writeFile(path.join(flutterDir, 'test', `${s}_test.dart`), genTest(s, P, M, L)),
81
+ writeFile(path.join(flutterDir, 'integration_test', 'app_test.dart'), genIntegrationTest(s, P, L)),
82
+ ]);
83
+ }
84
+
85
+ // ═══════════════════════════════════════════════════════════════════════════════
86
+ // Flutter/Dart file generators
87
+ // ═══════════════════════════════════════════════════════════════════════════════
88
+
89
+ function genPubspec(s, K, L, config) {
90
+ return `name: ${K}_app
91
+ description: Flutter application for ${L} Odoo module.
92
+ Generated by create-odoo-module — https://create-odoo-module.dev
93
+ version: 1.0.0+1
94
+ publish_to: 'none'
95
+
96
+ environment:
97
+ sdk: '>=3.3.0 <4.0.0'
98
+ flutter: '>=3.19.0'
99
+
100
+ dependencies:
101
+ flutter:
102
+ sdk: flutter
103
+
104
+ # ── Odoo / HTTP ────────────────────────────────────────────────────────────
105
+ odoo_rpc: ^0.4.0
106
+ http: ^1.2.0
107
+ dio: ^5.4.0
108
+
109
+ # ── State management ───────────────────────────────────────────────────────
110
+ flutter_riverpod: ^2.5.0
111
+ riverpod_annotation: ^2.3.0
112
+
113
+ # ── Navigation ─────────────────────────────────────────────────────────────
114
+ go_router: ^13.2.0
115
+
116
+ # ── Storage ────────────────────────────────────────────────────────────────
117
+ shared_preferences: ^2.2.0
118
+ flutter_secure_storage: ^9.0.0
119
+
120
+ # ── UI ─────────────────────────────────────────────────────────────────────
121
+ cupertino_icons: ^1.0.6
122
+ flutter_spinkit: ^5.2.0
123
+ cached_network_image: ^3.3.0
124
+ intl: ^0.19.0
125
+ gap: ^3.0.1
126
+ shimmer: ^3.0.0
127
+
128
+ dev_dependencies:
129
+ flutter_test:
130
+ sdk: flutter
131
+ integration_test:
132
+ sdk: flutter
133
+ flutter_lints: ^4.0.0
134
+ riverpod_generator: ^2.4.0
135
+ build_runner: ^2.4.0
136
+ mockito: ^5.4.0
137
+ build_verify: ^3.1.0
138
+
139
+ flutter:
140
+ uses-material-design: true
141
+ assets:
142
+ - assets/images/
143
+ - assets/icons/
144
+ `;
145
+ }
146
+
147
+ function genAnalysisOptions() {
148
+ return `include: package:flutter_lints/flutter.yaml
149
+
150
+ linter:
151
+ rules:
152
+ prefer_single_quotes: true
153
+ prefer_const_constructors: true
154
+ prefer_const_declarations: true
155
+ avoid_print: true
156
+ use_super_parameters: true
157
+ require_trailing_commas: true
158
+
159
+ analyzer:
160
+ exclude:
161
+ - "**/*.g.dart"
162
+ - "**/*.freezed.dart"
163
+ `;
164
+ }
165
+
166
+ function genFlutterGitignore() {
167
+ return `.dart_tool/
168
+ .flutter-plugins
169
+ .flutter-plugins-dependencies
170
+ .packages
171
+ build/
172
+ *.iml
173
+ *.lock
174
+ android/.gradle/
175
+ android/local.properties
176
+ ios/.symlinks/
177
+ ios/Pods/
178
+ ios/Flutter/Flutter.framework
179
+ ios/Flutter/Flutter.podspec
180
+ `;
181
+ }
182
+
183
+ function genFlutterReadme(L, M, s, config) {
184
+ return `# ${L} — Flutter App
185
+
186
+ Mobile application for the **${L}** Odoo module (\`${M}\`).
187
+ Generated by [create-odoo-module](https://create-odoo-module.dev).
188
+
189
+ ## Getting Started
190
+
191
+ \`\`\`bash
192
+ flutter pub get
193
+ flutter run
194
+ \`\`\`
195
+
196
+ ## Configuration
197
+
198
+ Edit \`lib/src/config/odoo_config.dart\` with your Odoo server details.
199
+
200
+ ## Architecture
201
+
202
+ | Layer | Description |
203
+ |-------|-------------|
204
+ | \`config/\` | Odoo server configuration |
205
+ | \`core/\` | JSON-RPC client + auth service |
206
+ | \`models/\` | Dart data models |
207
+ | \`repositories/\` | Data access layer (CRUD) |
208
+ | \`providers/\` | Riverpod state management |
209
+ | \`screens/\` | UI pages |
210
+ | \`widgets/\` | Reusable UI components |
211
+ `;
212
+ }
213
+
214
+ function genMain(s, P, L, config) {
215
+ return `import 'package:flutter/material.dart';
216
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
217
+ import 'package:shared_preferences/shared_preferences.dart';
218
+ import 'src/core/auth_service.dart';
219
+ import 'src/screens/login_screen.dart';
220
+ import 'src/screens/list_screen.dart';
221
+
222
+ void main() async {
223
+ WidgetsFlutterBinding.ensureInitialized();
224
+ final prefs = await SharedPreferences.getInstance();
225
+
226
+ runApp(
227
+ ProviderScope(
228
+ overrides: [
229
+ sharedPreferencesProvider.overrideWithValue(prefs),
230
+ ],
231
+ child: const ${P}App(),
232
+ ),
233
+ );
234
+ }
235
+
236
+ class ${P}App extends ConsumerWidget {
237
+ const ${P}App({super.key});
238
+
239
+ @override
240
+ Widget build(BuildContext context, WidgetRef ref) {
241
+ return MaterialApp(
242
+ title: '${L}',
243
+ debugShowCheckedModeBanner: false,
244
+ theme: ThemeData(
245
+ colorScheme: ColorScheme.fromSeed(
246
+ seedColor: const Color(0xFF875A7B), // Odoo brand purple
247
+ brightness: Brightness.light,
248
+ ),
249
+ useMaterial3: true,
250
+ appBarTheme: const AppBarTheme(
251
+ backgroundColor: Color(0xFF875A7B),
252
+ foregroundColor: Colors.white,
253
+ elevation: 0,
254
+ ),
255
+ cardTheme: CardTheme(
256
+ elevation: 2,
257
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
258
+ ),
259
+ inputDecorationTheme: InputDecorationTheme(
260
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
261
+ filled: true,
262
+ ),
263
+ elevatedButtonTheme: ElevatedButtonThemeData(
264
+ style: ElevatedButton.styleFrom(
265
+ backgroundColor: const Color(0xFF875A7B),
266
+ foregroundColor: Colors.white,
267
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
268
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
269
+ ),
270
+ ),
271
+ ),
272
+ home: const _AuthGate(),
273
+ );
274
+ }
275
+ }
276
+
277
+ class _AuthGate extends ConsumerWidget {
278
+ const _AuthGate();
279
+
280
+ @override
281
+ Widget build(BuildContext context, WidgetRef ref) {
282
+ final authAsync = ref.watch(authStateProvider);
283
+
284
+ return authAsync.when(
285
+ data: (isAuth) => isAuth ? const ListScreen() : const LoginScreen(),
286
+ loading: () => const Scaffold(
287
+ body: Center(child: CircularProgressIndicator()),
288
+ ),
289
+ error: (_, __) => const LoginScreen(),
290
+ );
291
+ }
292
+ }
293
+ `;
294
+ }
295
+
296
+ function genConfig(s, config) {
297
+ return `/// Odoo server configuration.
298
+ /// Edit these values to match your deployment.
299
+ class OdooConfig {
300
+ OdooConfig._();
301
+
302
+ /// Base URL of the Odoo server (no trailing slash)
303
+ static const String baseUrl = '${config.odooUrl || 'http://localhost:8069'}';
304
+
305
+ /// Database name
306
+ static const String defaultDatabase = '${config.odooDb || 'odoo'}';
307
+
308
+ /// HTTP request timeout in seconds
309
+ static const int timeoutSeconds = 30;
310
+
311
+ /// Number of records to load per page
312
+ static const int pageSize = 40;
313
+ }
314
+ `;
315
+ }
316
+
317
+ function genOdooClient(s, P, M) {
318
+ return `import 'package:flutter_riverpod/flutter_riverpod.dart';
319
+ import 'package:odoo_rpc/odoo_rpc.dart';
320
+ import 'package:shared_preferences/shared_preferences.dart';
321
+ import '../config/odoo_config.dart';
322
+ import 'exceptions.dart';
323
+
324
+ // ── Providers ─────────────────────────────────────────────────────────────────
325
+
326
+ final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
327
+ throw UnimplementedError('Override sharedPreferencesProvider in main()');
328
+ });
329
+
330
+ final odooClientProvider = Provider<OdooClient>((ref) {
331
+ return OdooClient(OdooConfig.baseUrl);
332
+ });
333
+
334
+ // ── OdooJsonRpcClient — thin wrapper ────────────────────────────────────────
335
+
336
+ class OdooJsonRpcClient {
337
+ final OdooClient _client;
338
+
339
+ OdooJsonRpcClient(this._client);
340
+
341
+ /// Authenticate with Odoo.
342
+ Future<OdooSession> authenticate(String db, String login, String password) async {
343
+ try {
344
+ return await _client.authenticate(db, login, password);
345
+ } on OdooSessionExpiredException {
346
+ throw OdooAuthException('Session expired');
347
+ } catch (e) {
348
+ throw OdooAuthException('Authentication failed: \$e');
349
+ }
350
+ }
351
+
352
+ /// Call a model method via JSON-RPC call_kw.
353
+ Future<dynamic> callKw({
354
+ required String model,
355
+ required String method,
356
+ required List<dynamic> args,
357
+ required Map<String, dynamic> kwargs,
358
+ }) async {
359
+ try {
360
+ return await _client.callKw({
361
+ 'model': model,
362
+ 'method': method,
363
+ 'args': args,
364
+ 'kwargs': kwargs,
365
+ });
366
+ } on OdooSessionExpiredException {
367
+ throw OdooSessionException('Your session has expired. Please log in again.');
368
+ } on OdooErrorException catch (e) {
369
+ throw OdooServerException(e.message);
370
+ } catch (e) {
371
+ throw OdooNetworkException('Network error: \$e');
372
+ }
373
+ }
374
+
375
+ /// Check if the current session is still valid.
376
+ Future<bool> checkSession() async {
377
+ try {
378
+ await _client.checkSession();
379
+ return true;
380
+ } catch (_) {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ /// Destroy current session (logout).
386
+ Future<void> destroySession() async {
387
+ try {
388
+ await _client.destroySession();
389
+ } catch (_) {}
390
+ }
391
+ }
392
+
393
+ final odooRpcClientProvider = Provider<OdooJsonRpcClient>((ref) {
394
+ final client = ref.watch(odooClientProvider);
395
+ return OdooJsonRpcClient(client);
396
+ });
397
+ `;
398
+ }
399
+
400
+ function genAuthService(s, P) {
401
+ return `import 'package:flutter_riverpod/flutter_riverpod.dart';
402
+ import 'package:shared_preferences/shared_preferences.dart';
403
+ import 'package:odoo_rpc/odoo_rpc.dart';
404
+ import '../config/odoo_config.dart';
405
+ import 'odoo_client.dart';
406
+
407
+ // ── Auth state provider ────────────────────────────────────────────────────────
408
+ final authStateProvider = FutureProvider<bool>((ref) async {
409
+ final prefs = ref.watch(sharedPreferencesProvider);
410
+ final sessionId = prefs.getString('odoo_session_id');
411
+ return sessionId != null && sessionId.isNotEmpty;
412
+ });
413
+
414
+ // ── Auth service ──────────────────────────────────────────────────────────────
415
+ class AuthService {
416
+ final OdooJsonRpcClient _rpc;
417
+ final SharedPreferences _prefs;
418
+
419
+ AuthService(this._rpc, this._prefs);
420
+
421
+ Future<bool> login(String db, String login, String password) async {
422
+ final session = await _rpc.authenticate(db, login, password);
423
+ await _prefs.setString('odoo_session_id', session.id);
424
+ await _prefs.setString('odoo_db', db);
425
+ await _prefs.setString('odoo_login', login);
426
+ await _prefs.setInt ('odoo_uid', session.userId);
427
+ return true;
428
+ }
429
+
430
+ Future<void> logout() async {
431
+ await _rpc.destroySession();
432
+ await _prefs.remove('odoo_session_id');
433
+ await _prefs.remove('odoo_db');
434
+ await _prefs.remove('odoo_login');
435
+ await _prefs.remove('odoo_uid');
436
+ }
437
+
438
+ bool get isLoggedIn => _prefs.getString('odoo_session_id')?.isNotEmpty == true;
439
+ int get userId => _prefs.getInt('odoo_uid') ?? 0;
440
+ String get database => _prefs.getString('odoo_db') ?? OdooConfig.defaultDatabase;
441
+ }
442
+
443
+ final authServiceProvider = Provider<AuthService>((ref) {
444
+ final rpc = ref.watch(odooRpcClientProvider);
445
+ final prefs = ref.watch(sharedPreferencesProvider);
446
+ return AuthService(rpc, prefs);
447
+ });
448
+ `;
449
+ }
450
+
451
+ function genExceptions() {
452
+ return `/// Base class for all Odoo-related exceptions.
453
+ abstract class OdooException implements Exception {
454
+ final String message;
455
+ const OdooException(this.message);
456
+ @override
457
+ String toString() => '\$runtimeType: \$message';
458
+ }
459
+
460
+ class OdooAuthException extends OdooException { const OdooAuthException(super.m); }
461
+ class OdooSessionException extends OdooException { const OdooSessionException(super.m); }
462
+ class OdooServerException extends OdooException { const OdooServerException(super.m); }
463
+ class OdooNetworkException extends OdooException { const OdooNetworkException(super.m); }
464
+ class OdooNotFoundException extends OdooException { const OdooNotFoundException(super.m); }
465
+ `;
466
+ }
467
+
468
+ function genModel(s, P, M, L) {
469
+ return `import 'package:flutter/foundation.dart';
470
+
471
+ @immutable
472
+ class ${P}Record {
473
+ final int id;
474
+ final String name;
475
+ final String reference;
476
+ final String state;
477
+ final String priority;
478
+ final String? description;
479
+ final DateTime? dateStart;
480
+ final DateTime? dateEnd;
481
+ final int durationDays;
482
+ final Map<String, dynamic>? userId;
483
+ final bool active;
484
+ final DateTime? createDate;
485
+
486
+ const ${P}Record({
487
+ required this.id,
488
+ required this.name,
489
+ required this.reference,
490
+ required this.state,
491
+ this.priority = '0',
492
+ this.description,
493
+ this.dateStart,
494
+ this.dateEnd,
495
+ this.durationDays = 0,
496
+ this.userId,
497
+ this.active = true,
498
+ this.createDate,
499
+ });
500
+
501
+ // ── Deserialise from Odoo search_read / read response ────────────────────
502
+ factory ${P}Record.fromMap(Map<String, dynamic> map) {
503
+ return ${P}Record(
504
+ id: map['id'] as int,
505
+ name: _s(map['name']),
506
+ reference: _s(map['reference']),
507
+ state: _s(map['state']),
508
+ priority: _s(map['priority'] ?? '0'),
509
+ description: map['description'] is String ? map['description'] as String : null,
510
+ dateStart: _parseDate(map['date_start']),
511
+ dateEnd: _parseDate(map['date_end']),
512
+ durationDays: (map['duration_days'] as num?)?.toInt() ?? 0,
513
+ userId: map['user_id'] is List
514
+ ? {'id': (map['user_id'] as List)[0], 'name': (map['user_id'] as List)[1]}
515
+ : null,
516
+ active: (map['active'] as bool?) ?? true,
517
+ createDate: map['create_date'] is String
518
+ ? DateTime.tryParse(map['create_date'] as String)
519
+ : null,
520
+ );
521
+ }
522
+
523
+ // ── Serialise for create / write ─────────────────────────────────────────
524
+ Map<String, dynamic> toMap() => {
525
+ 'name': name,
526
+ 'state': state,
527
+ 'priority': priority,
528
+ if (description != null) 'description': description,
529
+ if (dateStart != null) 'date_start': _fmtDate(dateStart!),
530
+ if (dateEnd != null) 'date_end': _fmtDate(dateEnd!),
531
+ };
532
+
533
+ ${P}Record copyWith({
534
+ String? name,
535
+ String? state,
536
+ String? priority,
537
+ String? description,
538
+ DateTime? dateStart,
539
+ DateTime? dateEnd,
540
+ }) => ${P}Record(
541
+ id: id,
542
+ name: name ?? this.name,
543
+ reference: reference,
544
+ state: state ?? this.state,
545
+ priority: priority ?? this.priority,
546
+ description: description ?? this.description,
547
+ dateStart: dateStart ?? this.dateStart,
548
+ dateEnd: dateEnd ?? this.dateEnd,
549
+ durationDays: durationDays,
550
+ userId: userId,
551
+ active: active,
552
+ createDate: createDate,
553
+ );
554
+
555
+ // ── Helpers ───────────────────────────────────────────────────────────────
556
+ String get responsibleName => (userId?['name'] as String?) ?? '—';
557
+
558
+ String get stateLabel {
559
+ const labels = {
560
+ 'draft': 'Draft',
561
+ 'confirmed': 'Confirmed',
562
+ 'in_progress': 'In Progress',
563
+ 'done': 'Done',
564
+ 'cancelled': 'Cancelled',
565
+ };
566
+ return labels[state] ?? state;
567
+ }
568
+
569
+ bool get isDraft => state == 'draft';
570
+ bool get isConfirmed => state == 'confirmed';
571
+ bool get isInProgress => state == 'in_progress';
572
+ bool get isDone => state == 'done';
573
+ bool get isCancelled => state == 'cancelled';
574
+
575
+ static String _s(dynamic v) => v is String ? v : '';
576
+ static String _fmtDate(DateTime d) => d.toIso8601String().split('T')[0];
577
+ static DateTime? _parseDate(dynamic v) => v is String ? DateTime.tryParse(v) : null;
578
+
579
+ @override
580
+ bool operator ==(Object other) => other is ${P}Record && other.id == id;
581
+
582
+ @override
583
+ int get hashCode => id.hashCode;
584
+ }
585
+ `;
586
+ }
587
+
588
+ function genRepository(s, P, M, L) {
589
+ return `import 'package:flutter_riverpod/flutter_riverpod.dart';
590
+ import '../core/odoo_client.dart';
591
+ import '../models/${s}_model.dart';
592
+
593
+ class ${P}Repository {
594
+ final OdooJsonRpcClient _rpc;
595
+
596
+ ${P}Repository(this._rpc);
597
+
598
+ static const _model = '${M}';
599
+ static const _listFields = [
600
+ 'id', 'name', 'reference', 'state', 'priority',
601
+ 'user_id', 'date_start', 'date_end', 'active',
602
+ ];
603
+ static const _detailFields = [
604
+ 'id', 'name', 'reference', 'state', 'priority', 'description',
605
+ 'user_id', 'company_id', 'tag_ids', 'date_start', 'date_end',
606
+ 'duration_days', 'active', 'create_date', 'write_date',
607
+ ];
608
+
609
+ // ── List records ──────────────────────────────────────────────────────────
610
+ Future<List<${P}Record>> fetchAll({
611
+ List<dynamic> domain = const [],
612
+ int limit = 40,
613
+ int offset = 0,
614
+ String order = 'name asc',
615
+ }) async {
616
+ final result = await _rpc.callKw(
617
+ model: _model,
618
+ method: 'search_read',
619
+ args: [],
620
+ kwargs: {
621
+ 'domain': [['active', '=', true], ...domain],
622
+ 'fields': _listFields,
623
+ 'limit': limit,
624
+ 'offset': offset,
625
+ 'order': order,
626
+ },
627
+ );
628
+ return (result as List).map((m) => ${P}Record.fromMap(m as Map<String, dynamic>)).toList();
629
+ }
630
+
631
+ // ── Total count ───────────────────────────────────────────────────────────
632
+ Future<int> count({List<dynamic> domain = const []}) async {
633
+ final result = await _rpc.callKw(
634
+ model: _model,
635
+ method: 'search_count',
636
+ args: [[['active', '=', true], ...domain]],
637
+ kwargs: {},
638
+ );
639
+ return (result as num).toInt();
640
+ }
641
+
642
+ // ── Single record ─────────────────────────────────────────────────────────
643
+ Future<${P}Record> fetchById(int id) async {
644
+ final result = await _rpc.callKw(
645
+ model: _model,
646
+ method: 'read',
647
+ args: [[id]],
648
+ kwargs: {'fields': _detailFields},
649
+ );
650
+ return ${P}Record.fromMap((result as List).first as Map<String, dynamic>);
651
+ }
652
+
653
+ // ── Create ────────────────────────────────────────────────────────────────
654
+ Future<int> create(Map<String, dynamic> values) async {
655
+ final id = await _rpc.callKw(
656
+ model: _model,
657
+ method: 'create',
658
+ args: [values],
659
+ kwargs: {},
660
+ );
661
+ return (id as num).toInt();
662
+ }
663
+
664
+ // ── Update ────────────────────────────────────────────────────────────────
665
+ Future<void> update(int id, Map<String, dynamic> values) async {
666
+ await _rpc.callKw(
667
+ model: _model,
668
+ method: 'write',
669
+ args: [[id], values],
670
+ kwargs: {},
671
+ );
672
+ }
673
+
674
+ // ── Delete ────────────────────────────────────────────────────────────────
675
+ Future<void> delete(int id) async {
676
+ await _rpc.callKw(
677
+ model: _model,
678
+ method: 'unlink',
679
+ args: [[id]],
680
+ kwargs: {},
681
+ );
682
+ }
683
+
684
+ // ── Business actions ──────────────────────────────────────────────────────
685
+ Future<void> callAction(int id, String method) async {
686
+ await _rpc.callKw(
687
+ model: _model,
688
+ method: method,
689
+ args: [[id]],
690
+ kwargs: {},
691
+ );
692
+ }
693
+
694
+ Future<void> confirm(int id) => callAction(id, 'action_confirm');
695
+ Future<void> start(int id) => callAction(id, 'action_start');
696
+ Future<void> markDone(int id) => callAction(id, 'action_done');
697
+ Future<void> cancel(int id) => callAction(id, 'action_cancel');
698
+ Future<void> resetDraft(int id) => callAction(id, 'action_reset_draft');
699
+ }
700
+
701
+ // ── Providers ─────────────────────────────────────────────────────────────────
702
+ final ${s}RepositoryProvider = Provider<${P}Repository>((ref) {
703
+ return ${P}Repository(ref.watch(odooRpcClientProvider));
704
+ });
705
+
706
+ final ${s}ListProvider = FutureProvider.family<List<${P}Record>, int>((ref, page) async {
707
+ const pageSize = 40;
708
+ return ref.watch(${s}RepositoryProvider).fetchAll(
709
+ limit: pageSize,
710
+ offset: page * pageSize,
711
+ );
712
+ });
713
+
714
+ final ${s}DetailProvider = FutureProvider.family<${P}Record, int>((ref, id) async {
715
+ return ref.watch(${s}RepositoryProvider).fetchById(id);
716
+ });
717
+ `;
718
+ }
719
+
720
+ function genProvider(s, P, M, L) {
721
+ return `import 'package:flutter_riverpod/flutter_riverpod.dart';
722
+ import '../models/${s}_model.dart';
723
+ import '../repositories/${s}_repository.dart';
724
+
725
+ // ── Notifier — manages the live list state ─────────────────────────────────────
726
+ class ${P}Notifier extends AsyncNotifier<List<${P}Record>> {
727
+ int _page = 0;
728
+ bool _hasMore = true;
729
+
730
+ @override
731
+ Future<List<${P}Record>> build() async {
732
+ _page = 0;
733
+ _hasMore = true;
734
+ return _fetch();
735
+ }
736
+
737
+ Future<List<${P}Record>> _fetch() async {
738
+ return ref.read(${s}RepositoryProvider).fetchAll(
739
+ offset: _page * 40,
740
+ limit: 40,
741
+ );
742
+ }
743
+
744
+ Future<void> refresh() async {
745
+ state = const AsyncValue.loading();
746
+ _page = 0;
747
+ state = await AsyncValue.guard(_fetch);
748
+ }
749
+
750
+ Future<void> loadMore() async {
751
+ if (!_hasMore || state.isLoading) return;
752
+ final current = state.valueOrNull ?? [];
753
+ _page++;
754
+ final next = await _fetch();
755
+ if (next.isEmpty) { _hasMore = false; return; }
756
+ state = AsyncValue.data([...current, ...next]);
757
+ }
758
+
759
+ // ── Mutation helpers ────────────────────────────────────────────────────────
760
+
761
+ Future<int> create(Map<String, dynamic> values) async {
762
+ final id = await ref.read(${s}RepositoryProvider).create(values);
763
+ refresh();
764
+ return id;
765
+ }
766
+
767
+ Future<void> update(int id, Map<String, dynamic> values) async {
768
+ await ref.read(${s}RepositoryProvider).update(id, values);
769
+ refresh();
770
+ }
771
+
772
+ Future<void> delete(int id) async {
773
+ await ref.read(${s}RepositoryProvider).delete(id);
774
+ state = AsyncValue.data(
775
+ (state.valueOrNull ?? []).where((r) => r.id != id).toList(),
776
+ );
777
+ }
778
+
779
+ Future<void> callAction(int id, String method) async {
780
+ await ref.read(${s}RepositoryProvider).callAction(id, method);
781
+ refresh();
782
+ }
783
+ }
784
+
785
+ final ${s}NotifierProvider = AsyncNotifierProvider<${P}Notifier, List<${P}Record>>(
786
+ ${P}Notifier.new,
787
+ );
788
+
789
+ // ── Search state ───────────────────────────────────────────────────────────────
790
+ final ${s}SearchProvider = StateProvider<String>((ref) => '');
791
+ final ${s}FilterStateProvider = StateProvider<String?>((ref) => null);
792
+ `;
793
+ }
794
+
795
+ function genLoginScreen(s, P, L) {
796
+ return `import 'package:flutter/material.dart';
797
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
798
+ import '../core/auth_service.dart';
799
+ import '../core/exceptions.dart';
800
+ import '../config/odoo_config.dart';
801
+ import 'list_screen.dart';
802
+
803
+ class LoginScreen extends ConsumerStatefulWidget {
804
+ const LoginScreen({super.key});
805
+
806
+ @override
807
+ ConsumerState<LoginScreen> createState() => _LoginScreenState();
808
+ }
809
+
810
+ class _LoginScreenState extends ConsumerState<LoginScreen> {
811
+ final _formKey = GlobalKey<FormState>();
812
+ final _urlCtrl = TextEditingController(text: OdooConfig.baseUrl);
813
+ final _dbCtrl = TextEditingController(text: OdooConfig.defaultDatabase);
814
+ final _loginCtrl = TextEditingController(text: 'admin');
815
+ final _passCtrl = TextEditingController();
816
+ bool _loading = false;
817
+ bool _obscurePass = true;
818
+ String? _error;
819
+
820
+ @override
821
+ void dispose() {
822
+ _urlCtrl.dispose(); _dbCtrl.dispose();
823
+ _loginCtrl.dispose(); _passCtrl.dispose();
824
+ super.dispose();
825
+ }
826
+
827
+ Future<void> _submit() async {
828
+ if (!_formKey.currentState!.validate()) return;
829
+ setState(() { _loading = true; _error = null; });
830
+ try {
831
+ await ref.read(authServiceProvider).login(
832
+ _dbCtrl.text.trim(),
833
+ _loginCtrl.text.trim(),
834
+ _passCtrl.text,
835
+ );
836
+ ref.invalidate(authStateProvider);
837
+ if (mounted) {
838
+ Navigator.of(context).pushReplacement(
839
+ MaterialPageRoute(builder: (_) => const ListScreen()),
840
+ );
841
+ }
842
+ } on OdooAuthException catch (e) {
843
+ setState(() { _error = e.message; });
844
+ } on OdooNetworkException catch (e) {
845
+ setState(() { _error = 'Cannot reach server: \${e.message}'; });
846
+ } catch (e) {
847
+ setState(() { _error = 'Unexpected error: \$e'; });
848
+ } finally {
849
+ if (mounted) setState(() => _loading = false);
850
+ }
851
+ }
852
+
853
+ @override
854
+ Widget build(BuildContext context) {
855
+ return Scaffold(
856
+ body: SafeArea(
857
+ child: Center(
858
+ child: SingleChildScrollView(
859
+ padding: const EdgeInsets.all(24),
860
+ child: ConstrainedBox(
861
+ constraints: const BoxConstraints(maxWidth: 420),
862
+ child: Form(
863
+ key: _formKey,
864
+ child: Column(
865
+ mainAxisSize: MainAxisSize.min,
866
+ crossAxisAlignment: CrossAxisAlignment.stretch,
867
+ children: [
868
+ // Logo / Title
869
+ const Icon(Icons.widgets_rounded, size: 64, color: Color(0xFF875A7B)),
870
+ const SizedBox(height: 16),
871
+ Text('${L}',
872
+ textAlign: TextAlign.center,
873
+ style: Theme.of(context).textTheme.headlineMedium?.copyWith(
874
+ fontWeight: FontWeight.bold, color: const Color(0xFF875A7B),
875
+ ),
876
+ ),
877
+ const SizedBox(height: 8),
878
+ Text('Sign in to your Odoo account',
879
+ textAlign: TextAlign.center,
880
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
881
+ ),
882
+ const SizedBox(height: 32),
883
+
884
+ // Error banner
885
+ if (_error != null) ...[
886
+ Container(
887
+ padding: const EdgeInsets.all(12),
888
+ decoration: BoxDecoration(
889
+ color: Colors.red.shade50,
890
+ border: Border.all(color: Colors.red.shade200),
891
+ borderRadius: BorderRadius.circular(8),
892
+ ),
893
+ child: Row(children: [
894
+ const Icon(Icons.error_outline, color: Colors.red, size: 18),
895
+ const SizedBox(width: 8),
896
+ Expanded(child: Text(_error!, style: const TextStyle(color: Colors.red))),
897
+ ]),
898
+ ),
899
+ const SizedBox(height: 16),
900
+ ],
901
+
902
+ // Server URL
903
+ TextFormField(
904
+ controller: _urlCtrl,
905
+ decoration: const InputDecoration(
906
+ labelText: 'Server URL',
907
+ prefixIcon: Icon(Icons.link),
908
+ hintText: 'http://localhost:8069',
909
+ ),
910
+ keyboardType: TextInputType.url,
911
+ validator: (v) =>
912
+ (v?.trim().isEmpty == true) ? 'Server URL is required' : null,
913
+ ),
914
+ const SizedBox(height: 12),
915
+
916
+ // Database
917
+ TextFormField(
918
+ controller: _dbCtrl,
919
+ decoration: const InputDecoration(
920
+ labelText: 'Database',
921
+ prefixIcon: Icon(Icons.storage),
922
+ ),
923
+ validator: (v) =>
924
+ (v?.trim().isEmpty == true) ? 'Database name is required' : null,
925
+ ),
926
+ const SizedBox(height: 12),
927
+
928
+ // Login
929
+ TextFormField(
930
+ controller: _loginCtrl,
931
+ decoration: const InputDecoration(
932
+ labelText: 'Username / Email',
933
+ prefixIcon: Icon(Icons.person),
934
+ ),
935
+ keyboardType: TextInputType.emailAddress,
936
+ validator: (v) =>
937
+ (v?.trim().isEmpty == true) ? 'Username is required' : null,
938
+ ),
939
+ const SizedBox(height: 12),
940
+
941
+ // Password
942
+ TextFormField(
943
+ controller: _passCtrl,
944
+ obscureText: _obscurePass,
945
+ decoration: InputDecoration(
946
+ labelText: 'Password',
947
+ prefixIcon: const Icon(Icons.lock),
948
+ suffixIcon: IconButton(
949
+ icon: Icon(_obscurePass ? Icons.visibility : Icons.visibility_off),
950
+ onPressed: () => setState(() => _obscurePass = !_obscurePass),
951
+ ),
952
+ ),
953
+ validator: (v) =>
954
+ (v?.isEmpty == true) ? 'Password is required' : null,
955
+ ),
956
+ const SizedBox(height: 24),
957
+
958
+ // Submit
959
+ ElevatedButton(
960
+ onPressed: _loading ? null : _submit,
961
+ child: _loading
962
+ ? const SizedBox.square(
963
+ dimension: 20,
964
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
965
+ )
966
+ : const Text('Sign In'),
967
+ ),
968
+ const SizedBox(height: 24),
969
+ Center(
970
+ child: Text(
971
+ 'Powered by create-odoo-module',
972
+ style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
973
+ ),
974
+ ),
975
+ ],
976
+ ),
977
+ ),
978
+ ),
979
+ ),
980
+ ),
981
+ ),
982
+ );
983
+ }
984
+ }
985
+ `;
986
+ }
987
+
988
+ function genListScreen(s, P, L) {
989
+ return `import 'package:flutter/material.dart';
990
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
991
+ import '../providers/${s}_provider.dart';
992
+ import '../widgets/${s}_card.dart';
993
+ import '../widgets/loading_overlay.dart';
994
+ import '../core/auth_service.dart';
995
+ import 'create_screen.dart';
996
+ import 'detail_screen.dart';
997
+ import 'login_screen.dart';
998
+
999
+ class ListScreen extends ConsumerStatefulWidget {
1000
+ const ListScreen({super.key});
1001
+
1002
+ @override
1003
+ ConsumerState<ListScreen> createState() => _ListScreenState();
1004
+ }
1005
+
1006
+ class _ListScreenState extends ConsumerState<ListScreen> {
1007
+ final _scrollCtrl = ScrollController();
1008
+
1009
+ @override
1010
+ void initState() {
1011
+ super.initState();
1012
+ _scrollCtrl.addListener(_onScroll);
1013
+ }
1014
+
1015
+ @override
1016
+ void dispose() {
1017
+ _scrollCtrl.dispose();
1018
+ super.dispose();
1019
+ }
1020
+
1021
+ void _onScroll() {
1022
+ if (_scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 200) {
1023
+ ref.read(${s}NotifierProvider.notifier).loadMore();
1024
+ }
1025
+ }
1026
+
1027
+ Future<void> _logout() async {
1028
+ await ref.read(authServiceProvider).logout();
1029
+ ref.invalidate(authStateProvider);
1030
+ if (mounted) {
1031
+ Navigator.of(context).pushAndRemoveUntil(
1032
+ MaterialPageRoute(builder: (_) => const LoginScreen()),
1033
+ (_) => false,
1034
+ );
1035
+ }
1036
+ }
1037
+
1038
+ @override
1039
+ Widget build(BuildContext context) {
1040
+ final recordsAsync = ref.watch(${s}NotifierProvider);
1041
+ final search = ref.watch(${s}SearchProvider);
1042
+
1043
+ return Scaffold(
1044
+ appBar: AppBar(
1045
+ title: const Text('${L}'),
1046
+ actions: [
1047
+ IconButton(
1048
+ icon: const Icon(Icons.refresh),
1049
+ tooltip: 'Refresh',
1050
+ onPressed: () => ref.read(${s}NotifierProvider.notifier).refresh(),
1051
+ ),
1052
+ PopupMenuButton<String>(
1053
+ onSelected: (v) { if (v == 'logout') _logout(); },
1054
+ itemBuilder: (_) => [
1055
+ const PopupMenuItem(value: 'logout', child: Row(children: [
1056
+ Icon(Icons.logout, size: 18), SizedBox(width: 8), Text('Logout'),
1057
+ ])),
1058
+ ],
1059
+ ),
1060
+ ],
1061
+ bottom: PreferredSize(
1062
+ preferredSize: const Size.fromHeight(56),
1063
+ child: Padding(
1064
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
1065
+ child: TextField(
1066
+ decoration: InputDecoration(
1067
+ hintText: 'Search ${L}…',
1068
+ prefixIcon: const Icon(Icons.search),
1069
+ filled: true,
1070
+ fillColor: Colors.white,
1071
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
1072
+ contentPadding: const EdgeInsets.symmetric(horizontal: 12),
1073
+ ),
1074
+ onChanged: (v) => ref.read(${s}SearchProvider.notifier).state = v,
1075
+ ),
1076
+ ),
1077
+ ),
1078
+ ),
1079
+ body: recordsAsync.when(
1080
+ data: (records) {
1081
+ final filtered = search.isEmpty
1082
+ ? records
1083
+ : records.where((r) => r.name.toLowerCase().contains(search.toLowerCase())).toList();
1084
+
1085
+ if (filtered.isEmpty) {
1086
+ return Center(
1087
+ child: Column(
1088
+ mainAxisSize: MainAxisSize.min,
1089
+ children: [
1090
+ Icon(Icons.inbox_rounded, size: 80, color: Colors.grey.shade300),
1091
+ const SizedBox(height: 16),
1092
+ Text(
1093
+ search.isNotEmpty ? 'No results for "\$search"' : 'No records yet',
1094
+ style: TextStyle(color: Colors.grey.shade500, fontSize: 16),
1095
+ ),
1096
+ if (search.isEmpty) ...[
1097
+ const SizedBox(height: 12),
1098
+ ElevatedButton.icon(
1099
+ onPressed: () => _openCreate(context),
1100
+ icon: const Icon(Icons.add),
1101
+ label: const Text('Create first record'),
1102
+ ),
1103
+ ],
1104
+ ],
1105
+ ),
1106
+ );
1107
+ }
1108
+
1109
+ return RefreshIndicator(
1110
+ onRefresh: () => ref.read(${s}NotifierProvider.notifier).refresh(),
1111
+ child: ListView.builder(
1112
+ controller: _scrollCtrl,
1113
+ padding: const EdgeInsets.all(12),
1114
+ itemCount: filtered.length,
1115
+ itemBuilder: (ctx, i) => ${P}Card(
1116
+ record: filtered[i],
1117
+ onTap: () => _openDetail(ctx, filtered[i].id),
1118
+ ),
1119
+ ),
1120
+ );
1121
+ },
1122
+ loading: () => const LoadingOverlay(message: 'Loading ${L}s…'),
1123
+ error: (e, _) => Center(
1124
+ child: Column(mainAxisSize: MainAxisSize.min, children: [
1125
+ const Icon(Icons.error_outline, color: Colors.red, size: 48),
1126
+ const SizedBox(height: 12),
1127
+ Text('\$e', style: const TextStyle(color: Colors.red)),
1128
+ const SizedBox(height: 12),
1129
+ ElevatedButton(
1130
+ onPressed: () => ref.read(${s}NotifierProvider.notifier).refresh(),
1131
+ child: const Text('Retry'),
1132
+ ),
1133
+ ]),
1134
+ ),
1135
+ ),
1136
+ floatingActionButton: FloatingActionButton.extended(
1137
+ onPressed: () => _openCreate(context),
1138
+ icon: const Icon(Icons.add),
1139
+ label: const Text('New'),
1140
+ ),
1141
+ );
1142
+ }
1143
+
1144
+ void _openCreate(BuildContext context) => Navigator.push(
1145
+ context,
1146
+ MaterialPageRoute(builder: (_) => const CreateScreen()),
1147
+ );
1148
+
1149
+ void _openDetail(BuildContext context, int id) => Navigator.push(
1150
+ context,
1151
+ MaterialPageRoute(builder: (_) => DetailScreen(recordId: id)),
1152
+ );
1153
+ }
1154
+ `;
1155
+ }
1156
+
1157
+ function genDetailScreen(s, P, M, L) {
1158
+ return `import 'package:flutter/material.dart';
1159
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
1160
+ import 'package:intl/intl.dart';
1161
+ import '../repositories/${s}_repository.dart';
1162
+ import '../providers/${s}_provider.dart';
1163
+ import '../widgets/state_badge.dart';
1164
+
1165
+ class DetailScreen extends ConsumerWidget {
1166
+ final int recordId;
1167
+ const DetailScreen({super.key, required this.recordId});
1168
+
1169
+ static final _dateFmt = DateFormat('MMM d, yyyy');
1170
+
1171
+ @override
1172
+ Widget build(BuildContext context, WidgetRef ref) {
1173
+ final detailAsync = ref.watch(${s}DetailProvider(recordId));
1174
+
1175
+ return Scaffold(
1176
+ appBar: AppBar(
1177
+ title: const Text('${L} Detail'),
1178
+ actions: [
1179
+ detailAsync.maybeWhen(
1180
+ data: (rec) => PopupMenuButton<String>(
1181
+ onSelected: (action) async {
1182
+ final notifier = ref.read(${s}NotifierProvider.notifier);
1183
+ try {
1184
+ await notifier.callAction(rec.id, 'action_\$action');
1185
+ ref.invalidate(${s}DetailProvider(recordId));
1186
+ if (context.mounted) {
1187
+ ScaffoldMessenger.of(context).showSnackBar(
1188
+ SnackBar(content: Text('Action "\$action" applied successfully')),
1189
+ );
1190
+ }
1191
+ } catch (e) {
1192
+ if (context.mounted) {
1193
+ ScaffoldMessenger.of(context).showSnackBar(
1194
+ SnackBar(content: Text('Error: \$e'), backgroundColor: Colors.red),
1195
+ );
1196
+ }
1197
+ }
1198
+ },
1199
+ itemBuilder: (_) {
1200
+ final actions = <PopupMenuItem<String>>[];
1201
+ if (rec.isDraft) actions.add(const PopupMenuItem(value: 'confirm', child: Text('Confirm')));
1202
+ if (rec.isConfirmed) actions.add(const PopupMenuItem(value: 'start', child: Text('Start')));
1203
+ if (rec.isInProgress) actions.add(const PopupMenuItem(value: 'done', child: Text('Mark Done')));
1204
+ if (!rec.isDone && !rec.isCancelled)
1205
+ actions.add(const PopupMenuItem(value: 'cancel', child: Text('Cancel')));
1206
+ if (rec.isCancelled) actions.add(const PopupMenuItem(value: 'reset_draft', child: Text('Reset Draft')));
1207
+ return actions;
1208
+ },
1209
+ ),
1210
+ orElse: () => const SizedBox.shrink(),
1211
+ ),
1212
+ ],
1213
+ ),
1214
+ body: detailAsync.when(
1215
+ data: (rec) => SingleChildScrollView(
1216
+ padding: const EdgeInsets.all(16),
1217
+ child: Column(
1218
+ crossAxisAlignment: CrossAxisAlignment.start,
1219
+ children: [
1220
+ // Header card
1221
+ Card(
1222
+ child: Padding(
1223
+ padding: const EdgeInsets.all(16),
1224
+ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
1225
+ Row(children: [
1226
+ Expanded(
1227
+ child: Text(rec.name,
1228
+ style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
1229
+ ),
1230
+ ),
1231
+ StateBadge(state: rec.state),
1232
+ ]),
1233
+ const SizedBox(height: 8),
1234
+ Text(rec.reference, style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
1235
+ ]),
1236
+ ),
1237
+ ),
1238
+ const SizedBox(height: 12),
1239
+
1240
+ // Info card
1241
+ Card(
1242
+ child: Padding(
1243
+ padding: const EdgeInsets.all(16),
1244
+ child: Column(children: [
1245
+ _InfoRow(icon: Icons.person_outline, label: 'Responsible', value: rec.responsibleName),
1246
+ _InfoRow(icon: Icons.calendar_today, label: 'Start Date', value: rec.dateStart != null ? _dateFmt.format(rec.dateStart!) : '—'),
1247
+ _InfoRow(icon: Icons.event, label: 'End Date', value: rec.dateEnd != null ? _dateFmt.format(rec.dateEnd!) : '—'),
1248
+ if (rec.durationDays > 0)
1249
+ _InfoRow(icon: Icons.timelapse, label: 'Duration', value: '\${rec.durationDays} days'),
1250
+ _InfoRow(icon: Icons.star_outline, label: 'Priority', value: ['Normal', 'Low', 'High', 'Very High'][int.tryParse(rec.priority) ?? 0]),
1251
+ ]),
1252
+ ),
1253
+ ),
1254
+
1255
+ if (rec.description?.isNotEmpty == true) ...[
1256
+ const SizedBox(height: 12),
1257
+ Card(
1258
+ child: Padding(
1259
+ padding: const EdgeInsets.all(16),
1260
+ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
1261
+ const Text('Description', style: TextStyle(fontWeight: FontWeight.bold)),
1262
+ const SizedBox(height: 8),
1263
+ Text(rec.description!),
1264
+ ]),
1265
+ ),
1266
+ ),
1267
+ ],
1268
+ ],
1269
+ ),
1270
+ ),
1271
+ loading: () => const Center(child: CircularProgressIndicator()),
1272
+ error: (e, _) => Center(child: Text('Error: \$e', style: const TextStyle(color: Colors.red))),
1273
+ ),
1274
+ );
1275
+ }
1276
+ }
1277
+
1278
+ class _InfoRow extends StatelessWidget {
1279
+ final IconData icon;
1280
+ final String label;
1281
+ final String value;
1282
+ const _InfoRow({required this.icon, required this.label, required this.value});
1283
+
1284
+ @override
1285
+ Widget build(BuildContext context) {
1286
+ return Padding(
1287
+ padding: const EdgeInsets.symmetric(vertical: 6),
1288
+ child: Row(children: [
1289
+ Icon(icon, size: 18, color: Colors.grey),
1290
+ const SizedBox(width: 12),
1291
+ SizedBox(width: 100, child: Text(label, style: TextStyle(color: Colors.grey.shade600))),
1292
+ Expanded(child: Text(value, style: const TextStyle(fontWeight: FontWeight.w500))),
1293
+ ]),
1294
+ );
1295
+ }
1296
+ }
1297
+ `;
1298
+ }
1299
+
1300
+ function genCreateScreen(s, P, M, L) {
1301
+ return `import 'package:flutter/material.dart';
1302
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
1303
+ import '../providers/${s}_provider.dart';
1304
+
1305
+ class CreateScreen extends ConsumerStatefulWidget {
1306
+ const CreateScreen({super.key});
1307
+
1308
+ @override
1309
+ ConsumerState<CreateScreen> createState() => _CreateScreenState();
1310
+ }
1311
+
1312
+ class _CreateScreenState extends ConsumerState<CreateScreen> {
1313
+ final _formKey = GlobalKey<FormState>();
1314
+ final _nameCtrl = TextEditingController();
1315
+ final _descCtrl = TextEditingController();
1316
+ DateTime? _dateStart;
1317
+ DateTime? _dateEnd;
1318
+ String _priority = '0';
1319
+ bool _saving = false;
1320
+ String? _error;
1321
+
1322
+ @override
1323
+ void dispose() {
1324
+ _nameCtrl.dispose();
1325
+ _descCtrl.dispose();
1326
+ super.dispose();
1327
+ }
1328
+
1329
+ Future<void> _save() async {
1330
+ if (!_formKey.currentState!.validate()) return;
1331
+ setState(() { _saving = true; _error = null; });
1332
+
1333
+ try {
1334
+ final values = <String, dynamic>{
1335
+ 'name': _nameCtrl.text.trim(),
1336
+ 'priority': _priority,
1337
+ if (_descCtrl.text.isNotEmpty) 'description': _descCtrl.text.trim(),
1338
+ if (_dateStart != null) 'date_start': _dateStart!.toIso8601String().split('T')[0],
1339
+ if (_dateEnd != null) 'date_end': _dateEnd!.toIso8601String().split('T')[0],
1340
+ };
1341
+ await ref.read(${s}NotifierProvider.notifier).create(values);
1342
+ if (mounted) {
1343
+ ScaffoldMessenger.of(context).showSnackBar(
1344
+ const SnackBar(content: Text('Record created successfully!'), backgroundColor: Colors.green),
1345
+ );
1346
+ Navigator.of(context).pop();
1347
+ }
1348
+ } catch (e) {
1349
+ setState(() { _error = '\$e'; });
1350
+ } finally {
1351
+ if (mounted) setState(() => _saving = false);
1352
+ }
1353
+ }
1354
+
1355
+ @override
1356
+ Widget build(BuildContext context) {
1357
+ return Scaffold(
1358
+ appBar: AppBar(
1359
+ title: const Text('New ${L}'),
1360
+ actions: [
1361
+ TextButton(
1362
+ onPressed: _saving ? null : _save,
1363
+ child: _saving
1364
+ ? const SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
1365
+ : const Text('Save', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
1366
+ ),
1367
+ ],
1368
+ ),
1369
+ body: SingleChildScrollView(
1370
+ padding: const EdgeInsets.all(16),
1371
+ child: Form(
1372
+ key: _formKey,
1373
+ child: Column(children: [
1374
+ if (_error != null)
1375
+ Container(
1376
+ width: double.infinity,
1377
+ padding: const EdgeInsets.all(12),
1378
+ margin: const EdgeInsets.only(bottom: 16),
1379
+ decoration: BoxDecoration(
1380
+ color: Colors.red.shade50,
1381
+ border: Border.all(color: Colors.red.shade200),
1382
+ borderRadius: BorderRadius.circular(8),
1383
+ ),
1384
+ child: Text(_error!, style: const TextStyle(color: Colors.red)),
1385
+ ),
1386
+
1387
+ TextFormField(
1388
+ controller: _nameCtrl,
1389
+ decoration: const InputDecoration(labelText: 'Name *', prefixIcon: Icon(Icons.label)),
1390
+ validator: (v) => v?.trim().isEmpty == true ? 'Name is required' : null,
1391
+ textInputAction: TextInputAction.next,
1392
+ ),
1393
+ const SizedBox(height: 12),
1394
+
1395
+ DropdownButtonFormField<String>(
1396
+ value: _priority,
1397
+ decoration: const InputDecoration(labelText: 'Priority', prefixIcon: Icon(Icons.star_outline)),
1398
+ items: const [
1399
+ DropdownMenuItem(value: '0', child: Text('Normal')),
1400
+ DropdownMenuItem(value: '1', child: Text('Low')),
1401
+ DropdownMenuItem(value: '2', child: Text('High')),
1402
+ DropdownMenuItem(value: '3', child: Text('Very High')),
1403
+ ],
1404
+ onChanged: (v) => setState(() => _priority = v ?? '0'),
1405
+ ),
1406
+ const SizedBox(height: 12),
1407
+
1408
+ Row(children: [
1409
+ Expanded(
1410
+ child: ListTile(
1411
+ contentPadding: EdgeInsets.zero,
1412
+ leading: const Icon(Icons.calendar_today),
1413
+ title: Text(_dateStart == null ? 'Start Date' : _dateStart!.toString().split(' ')[0]),
1414
+ onTap: () async {
1415
+ final d = await showDatePicker(
1416
+ context: context,
1417
+ initialDate: _dateStart ?? DateTime.now(),
1418
+ firstDate: DateTime(2000),
1419
+ lastDate: DateTime(2100),
1420
+ );
1421
+ if (d != null) setState(() => _dateStart = d);
1422
+ },
1423
+ ),
1424
+ ),
1425
+ Expanded(
1426
+ child: ListTile(
1427
+ contentPadding: EdgeInsets.zero,
1428
+ leading: const Icon(Icons.event),
1429
+ title: Text(_dateEnd == null ? 'End Date' : _dateEnd!.toString().split(' ')[0]),
1430
+ onTap: () async {
1431
+ final d = await showDatePicker(
1432
+ context: context,
1433
+ initialDate: _dateEnd ?? DateTime.now(),
1434
+ firstDate: DateTime(2000),
1435
+ lastDate: DateTime(2100),
1436
+ );
1437
+ if (d != null) setState(() => _dateEnd = d);
1438
+ },
1439
+ ),
1440
+ ),
1441
+ ]),
1442
+ const SizedBox(height: 12),
1443
+
1444
+ TextFormField(
1445
+ controller: _descCtrl,
1446
+ decoration: const InputDecoration(labelText: 'Description', prefixIcon: Icon(Icons.notes), alignLabelWithHint: true),
1447
+ maxLines: 4,
1448
+ textInputAction: TextInputAction.newline,
1449
+ ),
1450
+ ]),
1451
+ ),
1452
+ ),
1453
+ );
1454
+ }
1455
+ }
1456
+ `;
1457
+ }
1458
+
1459
+ function genCard(s, P, L) {
1460
+ return `import 'package:flutter/material.dart';
1461
+ import 'package:intl/intl.dart';
1462
+ import '../models/${s}_model.dart';
1463
+ import 'state_badge.dart';
1464
+
1465
+ class ${P}Card extends StatelessWidget {
1466
+ final ${P}Record record;
1467
+ final VoidCallback? onTap;
1468
+
1469
+ const ${P}Card({super.key, required this.record, this.onTap});
1470
+
1471
+ static final _dateFmt = DateFormat('MMM d, yyyy');
1472
+
1473
+ @override
1474
+ Widget build(BuildContext context) {
1475
+ return Card(
1476
+ margin: const EdgeInsets.only(bottom: 8),
1477
+ child: InkWell(
1478
+ onTap: onTap,
1479
+ borderRadius: BorderRadius.circular(12),
1480
+ child: Padding(
1481
+ padding: const EdgeInsets.all(12),
1482
+ child: Row(children: [
1483
+ // Avatar
1484
+ CircleAvatar(
1485
+ backgroundColor: const Color(0xFF875A7B),
1486
+ radius: 22,
1487
+ child: Text(
1488
+ record.name.isNotEmpty ? record.name[0].toUpperCase() : '?',
1489
+ style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
1490
+ ),
1491
+ ),
1492
+ const SizedBox(width: 12),
1493
+ // Content
1494
+ Expanded(
1495
+ child: Column(
1496
+ crossAxisAlignment: CrossAxisAlignment.start,
1497
+ children: [
1498
+ Row(children: [
1499
+ Expanded(
1500
+ child: Text(record.name,
1501
+ style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
1502
+ maxLines: 1, overflow: TextOverflow.ellipsis,
1503
+ ),
1504
+ ),
1505
+ StateBadge(state: record.state, compact: true),
1506
+ ]),
1507
+ const SizedBox(height: 4),
1508
+ Text(record.reference,
1509
+ style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
1510
+ ),
1511
+ if (record.dateStart != null || record.responsibleName.isNotEmpty) ...[
1512
+ const SizedBox(height: 4),
1513
+ Row(children: [
1514
+ if (record.responsibleName.isNotEmpty) ...[
1515
+ Icon(Icons.person_outline, size: 12, color: Colors.grey.shade400),
1516
+ const SizedBox(width: 3),
1517
+ Text(record.responsibleName, style: TextStyle(color: Colors.grey.shade500, fontSize: 12)),
1518
+ const SizedBox(width: 8),
1519
+ ],
1520
+ if (record.dateStart != null) ...[
1521
+ Icon(Icons.calendar_today, size: 12, color: Colors.grey.shade400),
1522
+ const SizedBox(width: 3),
1523
+ Text(_dateFmt.format(record.dateStart!), style: TextStyle(color: Colors.grey.shade500, fontSize: 12)),
1524
+ ],
1525
+ ]),
1526
+ ],
1527
+ ],
1528
+ ),
1529
+ ),
1530
+ const Icon(Icons.chevron_right, color: Colors.grey),
1531
+ ]),
1532
+ ),
1533
+ ),
1534
+ );
1535
+ }
1536
+ }
1537
+ `;
1538
+ }
1539
+
1540
+ function genStateBadge() {
1541
+ return `import 'package:flutter/material.dart';
1542
+
1543
+ /// Coloured badge that reflects an Odoo record state.
1544
+ class StateBadge extends StatelessWidget {
1545
+ final String state;
1546
+ final bool compact;
1547
+ const StateBadge({super.key, required this.state, this.compact = false});
1548
+
1549
+ static const _config = <String, (Color, Color, String)>{
1550
+ 'draft': (Color(0xFFE3F2FD), Color(0xFF1565C0), 'Draft'),
1551
+ 'confirmed': (Color(0xFFFFF3E0), Color(0xFFE65100), 'Confirmed'),
1552
+ 'in_progress': (Color(0xFFFFF9C4), Color(0xFFF57F17), 'In Progress'),
1553
+ 'done': (Color(0xFFE8F5E9), Color(0xFF2E7D32), 'Done'),
1554
+ 'cancelled': (Color(0xFFFFEBEE), Color(0xFFC62828), 'Cancelled'),
1555
+ };
1556
+
1557
+ @override
1558
+ Widget build(BuildContext context) {
1559
+ final (bg, fg, label) = _config[state] ?? (Colors.grey.shade100, Colors.grey, state);
1560
+ return Container(
1561
+ padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 10, vertical: compact ? 2 : 4),
1562
+ decoration: BoxDecoration(
1563
+ color: bg,
1564
+ borderRadius: BorderRadius.circular(20),
1565
+ ),
1566
+ child: Text(
1567
+ label,
1568
+ style: TextStyle(color: fg, fontSize: compact ? 10 : 12, fontWeight: FontWeight.w600),
1569
+ ),
1570
+ );
1571
+ }
1572
+ }
1573
+ `;
1574
+ }
1575
+
1576
+ function genLoadingOverlay() {
1577
+ return `import 'package:flutter/material.dart';
1578
+ import 'package:flutter_spinkit/flutter_spinkit.dart';
1579
+
1580
+ class LoadingOverlay extends StatelessWidget {
1581
+ final String? message;
1582
+ const LoadingOverlay({super.key, this.message});
1583
+
1584
+ @override
1585
+ Widget build(BuildContext context) {
1586
+ return Center(
1587
+ child: Column(
1588
+ mainAxisSize: MainAxisSize.min,
1589
+ children: [
1590
+ const SpinKitThreeBounce(color: Color(0xFF875A7B), size: 30),
1591
+ if (message != null) ...[
1592
+ const SizedBox(height: 16),
1593
+ Text(message!, style: TextStyle(color: Colors.grey.shade600)),
1594
+ ],
1595
+ ],
1596
+ ),
1597
+ );
1598
+ }
1599
+ }
1600
+ `;
1601
+ }
1602
+
1603
+ function genTest(s, P, M, L) {
1604
+ return `import 'package:flutter_test/flutter_test.dart';
1605
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
1606
+ import 'package:${s.replace(/_/g, '_')}_app/src/models/${s}_model.dart';
1607
+
1608
+ void main() {
1609
+ group('${P}Record model tests', () {
1610
+ final sampleMap = {
1611
+ 'id': 1,
1612
+ 'name': 'Test Record',
1613
+ 'reference': 'REF/2025/00001',
1614
+ 'state': 'draft',
1615
+ 'priority': '0',
1616
+ 'user_id': [1, 'Admin'],
1617
+ 'date_start':'2025-01-01',
1618
+ 'date_end': '2025-01-11',
1619
+ 'active': true,
1620
+ 'duration_days': 10,
1621
+ };
1622
+
1623
+ test('fromMap parses correctly', () {
1624
+ final record = ${P}Record.fromMap(sampleMap);
1625
+ expect(record.id, 1);
1626
+ expect(record.name, 'Test Record');
1627
+ expect(record.state, 'draft');
1628
+ expect(record.isDraft, true);
1629
+ expect(record.dateStart, DateTime(2025, 1, 1));
1630
+ expect(record.durationDays, 10);
1631
+ expect(record.responsibleName, 'Admin');
1632
+ });
1633
+
1634
+ test('stateLabel returns correct label', () {
1635
+ final draft = ${P}Record.fromMap({...sampleMap, 'state': 'draft'});
1636
+ final confirmed = ${P}Record.fromMap({...sampleMap, 'state': 'confirmed'});
1637
+ final done = ${P}Record.fromMap({...sampleMap, 'state': 'done'});
1638
+ expect(draft.stateLabel, 'Draft');
1639
+ expect(confirmed.stateLabel, 'Confirmed');
1640
+ expect(done.stateLabel, 'Done');
1641
+ });
1642
+
1643
+ test('copyWith creates modified copy', () {
1644
+ final rec = ${P}Record.fromMap(sampleMap);
1645
+ final copy = rec.copyWith(name: 'Updated', state: 'confirmed');
1646
+ expect(copy.name, 'Updated');
1647
+ expect(copy.state, 'confirmed');
1648
+ expect(copy.id, rec.id); // ID unchanged
1649
+ });
1650
+
1651
+ test('equality is based on id', () {
1652
+ final a = ${P}Record.fromMap(sampleMap);
1653
+ final b = ${P}Record.fromMap({...sampleMap, 'name': 'Different Name'});
1654
+ expect(a, equals(b)); // same id → equal
1655
+ });
1656
+
1657
+ test('toMap returns correct create payload', () {
1658
+ final rec = ${P}Record.fromMap(sampleMap);
1659
+ final map = rec.toMap();
1660
+ expect(map['name'], 'Test Record');
1661
+ expect(map['state'], 'draft');
1662
+ expect(map.containsKey('id'), false); // id not in create payload
1663
+ expect(map.containsKey('reference'), false); // reference is server-generated
1664
+ });
1665
+ });
1666
+ }
1667
+ `;
1668
+ }
1669
+
1670
+ function genIntegrationTest(s, P, L) {
1671
+ return `// Integration tests for ${L} Flutter app.
1672
+ // Requires a running Odoo instance and a valid session.
1673
+ // Run with: flutter test integration_test/app_test.dart
1674
+
1675
+ import 'package:flutter_test/flutter_test.dart';
1676
+ import 'package:integration_test/integration_test.dart';
1677
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
1678
+
1679
+ void main() {
1680
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
1681
+
1682
+ testWidgets('Login screen renders', (tester) async {
1683
+ // TODO: Boot the app and verify the login screen appears
1684
+ // await tester.pumpWidget(ProviderScope(child: FleetManagerApp()));
1685
+ // expect(find.text('Sign In'), findsOneWidget);
1686
+ });
1687
+
1688
+ testWidgets('List screen loads records after login', (tester) async {
1689
+ // TODO: Inject a mock OdooClient and verify the list screen
1690
+ });
1691
+ }
1692
+ `;
1693
+ }
1694
+
1695
+ module.exports = { generateFlutterApp };