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.
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/bin/create-odoo-module.js +147 -0
- package/package.json +84 -0
- package/src/cli/args-parser.js +36 -0
- package/src/cli/index.js +272 -0
- package/src/cli/prompts.js +151 -0
- package/src/cli/validator.js +72 -0
- package/src/config/defaults.js +41 -0
- package/src/config/pro-config.js +55 -0
- package/src/generators/api-generator.js +340 -0
- package/src/generators/deploy-generator.js +390 -0
- package/src/generators/flutter-generator.js +1695 -0
- package/src/generators/odoo-generator.js +794 -0
- package/src/generators/ui-generator.js +329 -0
- package/src/utils/file-system.js +67 -0
- package/src/utils/license-check.js +57 -0
- package/src/utils/logger.js +32 -0
- package/src/utils/spinner.js +32 -0
- package/src/utils/string-utils.js +65 -0
|
@@ -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 };
|