codz-cli 0.0.0-stage → 0.1.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/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +523 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +358 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// Android (Jetpack Compose) screens: route add, screen add (data) and
|
|
2
|
+
// screen add --form.
|
|
3
|
+
//
|
|
4
|
+
// <Name>Screen.kt a @Composable in the screens folder
|
|
5
|
+
// <Name>ViewModel.kt data and form screens: the API interface comes in
|
|
6
|
+
// through Hilt (@HiltViewModel), Koin (viewModelOf) or
|
|
7
|
+
// the service locator (a ViewModel factory); route
|
|
8
|
+
// parameters come from SavedStateHandle
|
|
9
|
+
// UiState.kt Loading / Success / Error, written once
|
|
10
|
+
// NavHost a composable("route/{id}") { } entry
|
|
11
|
+
//
|
|
12
|
+
// A data screen shows a spinner, the error with Retry, then a LazyColumn (for
|
|
13
|
+
// lists) or the fields of the model. A form screen has one field per request
|
|
14
|
+
// property and sends it through the ViewModel.
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { packageOf } from "../adapters/android.js";
|
|
17
|
+
import { addKotlinImports, kotlinName } from "../adapters/kotlinGen.js";
|
|
18
|
+
import { matchBrace } from "../adapters/source.js";
|
|
19
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
20
|
+
import { readText } from "../core/fsutil.js";
|
|
21
|
+
import { ToolError } from "../core/types.js";
|
|
22
|
+
import { camel, kebab, pascal, words } from "../input/naming.js";
|
|
23
|
+
import { responseShape, titleField } from "./screen.js";
|
|
24
|
+
import { prefillFor } from "./prefill.js";
|
|
25
|
+
import { planViewsRoute, planViewsScreen } from "./androidViews.js";
|
|
26
|
+
/** A Kotlin `fun name(...)`: its parameters and return type. */
|
|
27
|
+
export function kotlinFun(text, name) {
|
|
28
|
+
const m = new RegExp(`((?:^[ \\t]*(?:/\\*\\*(?:(?!\\*/)[^])*\\*/|//[^\\n]*|@\\w+(?:\\([^\\n]*\\))?)[ \\t]*\\n)*)[ \\t]*(?:suspend\\s+)?fun\\s+${name}\\s*\\(`, 'm').exec(text);
|
|
29
|
+
if (!m)
|
|
30
|
+
return null;
|
|
31
|
+
const open = m.index + m[0].length - 1;
|
|
32
|
+
const close = matchBrace(text, open, 'dart');
|
|
33
|
+
if (close === -1)
|
|
34
|
+
return null;
|
|
35
|
+
const rest = text.slice(close + 1);
|
|
36
|
+
const ret = /^\s*:\s*([\w<>?, .]+?)\s*(?:\n|=|\{|$)/.exec(rest);
|
|
37
|
+
const lineEnd = text.indexOf('\n', close);
|
|
38
|
+
const params = [];
|
|
39
|
+
for (const raw of text.slice(open + 1, close).split(/,(?![^<(]*[>)])/)) {
|
|
40
|
+
const p = /^\s*((?:@\w+(?:\([^)]*\))?\s*)*)(\w+|`\w+`)\s*:\s*([\w<>?, .]+?)\s*(=.*)?$/.exec(raw.replace(/\s+/g, ' '));
|
|
41
|
+
if (!p)
|
|
42
|
+
continue;
|
|
43
|
+
const ann = /@(\w+)(?:\(\s*"([^"]*)"\s*\))?/.exec(p[1]);
|
|
44
|
+
params.push({ name: p[2], type: p[3].trim(), annotation: ann?.[1] ?? '', wire: ann?.[2], optional: !!p[4] || p[3].trim().endsWith('?') });
|
|
45
|
+
}
|
|
46
|
+
return { params, returnType: ret?.[1]?.trim() ?? 'Unit', start: m.index, end: lineEnd === -1 ? text.length : lineEnd };
|
|
47
|
+
}
|
|
48
|
+
export function names(name) {
|
|
49
|
+
const base = pascal(name.replace(/[-_ ]?screen$/i, ''));
|
|
50
|
+
return { base, screen: `${base}Screen`, vm: `${base}ViewModel`, title: words(base).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') };
|
|
51
|
+
}
|
|
52
|
+
/** "/products/:id" → { route: "products/{id}", params: ["id"] } */
|
|
53
|
+
export function routeOf(name, path) {
|
|
54
|
+
const segs = (path ?? `/${kebab(name.replace(/[-_ ]?screen$/i, ''))}`).split('/').filter(Boolean).map((s) => s.replace(/^:([\w-]+)$/, '{$1}').replace(/^\[([\w-]+)\]$/, '{$1}'));
|
|
55
|
+
const route = segs.map((s) => s.replace(/^\{([\w-]+)\}$/, (_m, p) => `{${camel(p)}}`)).join('/');
|
|
56
|
+
return { route, params: [...route.matchAll(/\{(\w+)\}/g)].map((x) => x[1]) };
|
|
57
|
+
}
|
|
58
|
+
export const fileOf = (dir, cls) => `${dir}/${cls}.kt`;
|
|
59
|
+
export const dirOf = (f) => f.slice(0, f.lastIndexOf('/'));
|
|
60
|
+
export const classOfFile = (srcDir, f) => `${packageOf(srcDir, dirOf(f))}.${f.slice(f.lastIndexOf('/') + 1, -3)}`;
|
|
61
|
+
export function context(root, map, ui = 'compose') {
|
|
62
|
+
const c = map.conventions;
|
|
63
|
+
if (ui === 'compose' && c.ui !== 'compose')
|
|
64
|
+
throw new ToolError('not_supported', 'screen add and route add write Jetpack Compose screens, and this project uses Views.', 'Set ui: compose in codz.config.yaml if the app uses Compose.');
|
|
65
|
+
if (!c.namespace)
|
|
66
|
+
throw new ToolError('no_package', 'No namespace was found in the app build file.', 'Set namespace in codz.config.yaml.');
|
|
67
|
+
const files = new Map();
|
|
68
|
+
return { root, map, c, srcDir: c.srcDir || 'app/src/main/java', files, summary: [], warnings: [], read: (p) => files.get(p) ?? readText(join(root, p)) };
|
|
69
|
+
}
|
|
70
|
+
/** Adds composable("route") { ... } to the NavHost. */
|
|
71
|
+
function registerRoute(x, route, call, screenClass, params, withArgs) {
|
|
72
|
+
const nav = x.c.navFile;
|
|
73
|
+
const text = nav ? x.read(nav) : null;
|
|
74
|
+
if (!nav || text === null) {
|
|
75
|
+
x.warnings.push(`No NavHost was found, so the screen is not registered. Add composable("${route}") { ${call} } to your NavHost (or set navFile in codz.config.yaml).`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (new RegExp(`composable\\(\\s*(?:route\\s*=\\s*)?"${route.replace(/[{}]/g, '\\$&')}"`).test(text))
|
|
79
|
+
throw new ToolError('route_exists', `${nav} already has a route "${route}".`);
|
|
80
|
+
const host = /\bNavHost\s*\(/.exec(text);
|
|
81
|
+
const argsClose = matchBrace(text, host.index + host[0].length - 1, 'dart');
|
|
82
|
+
const lambda = text.indexOf('{', argsClose);
|
|
83
|
+
const close = matchBrace(text, lambda, 'dart');
|
|
84
|
+
const indent = /\n([ \t]*)[^\n]*$/.exec(text.slice(0, close))?.[1] ?? ' ';
|
|
85
|
+
const inner = `${indent} `;
|
|
86
|
+
const entry = withArgs && params.length ? `${inner}composable("${route}") { entry ->\n${inner} ${call}\n${inner}}` : `${inner}composable("${route}") {\n${inner} ${call}\n${inner}}`;
|
|
87
|
+
const before = text.slice(0, close).replace(/\s*$/, '');
|
|
88
|
+
let t = `${before}\n${entry}\n${indent}${text.slice(close)}`;
|
|
89
|
+
t = addKotlinImports(t, [screenClass], /^package\s+([\w.]+)/m.exec(t)?.[1] ?? '');
|
|
90
|
+
x.files.set(nav, t);
|
|
91
|
+
x.summary.push(`Update ${nav}: composable("${route}")`);
|
|
92
|
+
}
|
|
93
|
+
function navHint(route, params) {
|
|
94
|
+
return `Open it with: navController.navigate("${route.replace(/\{(\w+)\}/g, '$$$1')}")${params.length ? '' : ''}`;
|
|
95
|
+
}
|
|
96
|
+
export function finish(x, skill, hint) {
|
|
97
|
+
x.summary.push(hint);
|
|
98
|
+
return buildChangeSet(x.root, skill, { files: x.files, summary: x.summary, warnings: x.warnings, generated: [] });
|
|
99
|
+
}
|
|
100
|
+
export function planAndroidRoute(root, map, opts) {
|
|
101
|
+
if (map.conventions.ui === 'views')
|
|
102
|
+
return planViewsRoute(root, map, opts);
|
|
103
|
+
const x = context(root, map);
|
|
104
|
+
const n = names(opts.name);
|
|
105
|
+
const { route, params } = routeOf(opts.name, opts.path);
|
|
106
|
+
const file = fileOf(x.c.screensFolder || `${x.srcDir}/${x.c.namespace.replace(/\./g, '/')}/ui/screens`, n.screen);
|
|
107
|
+
if (x.read(file) !== null)
|
|
108
|
+
throw new ToolError('route_exists', `${file} already exists.`);
|
|
109
|
+
const pkg = packageOf(x.srcDir, dirOf(file));
|
|
110
|
+
const sig = params.map((p) => `${p}: String`).join(', ');
|
|
111
|
+
x.files.set(file, [
|
|
112
|
+
`package ${pkg}`,
|
|
113
|
+
'',
|
|
114
|
+
'import androidx.compose.foundation.layout.Column',
|
|
115
|
+
'import androidx.compose.foundation.layout.fillMaxSize',
|
|
116
|
+
'import androidx.compose.foundation.layout.padding',
|
|
117
|
+
'import androidx.compose.material3.MaterialTheme',
|
|
118
|
+
'import androidx.compose.material3.Scaffold',
|
|
119
|
+
'import androidx.compose.material3.Text',
|
|
120
|
+
'import androidx.compose.runtime.Composable',
|
|
121
|
+
'import androidx.compose.ui.Modifier',
|
|
122
|
+
'import androidx.compose.ui.unit.dp',
|
|
123
|
+
'',
|
|
124
|
+
'@Composable',
|
|
125
|
+
`fun ${n.screen}(${sig}) {`,
|
|
126
|
+
' Scaffold { padding ->',
|
|
127
|
+
' Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {',
|
|
128
|
+
` Text("${n.title}", style = MaterialTheme.typography.headlineSmall)`,
|
|
129
|
+
...params.map((p) => ` Text("${p}: $${p}")`),
|
|
130
|
+
' }',
|
|
131
|
+
' }',
|
|
132
|
+
'}',
|
|
133
|
+
'',
|
|
134
|
+
].join('\n'));
|
|
135
|
+
x.summary.push(`Create ${file}: ${n.screen}`);
|
|
136
|
+
const call = `${n.screen}(${params.map((p) => `${p} = entry.arguments?.getString("${p}").orEmpty()`).join(', ')})`;
|
|
137
|
+
registerRoute(x, route, call, `${pkg}.${n.screen}`, params, true);
|
|
138
|
+
return finish(x, 'route add', navHint(route, params));
|
|
139
|
+
}
|
|
140
|
+
export function apiRef(x, name) {
|
|
141
|
+
const ep = x.map.endpoints.find((e) => e.name === name) ?? x.map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
142
|
+
if (!ep)
|
|
143
|
+
throw new ToolError('no_endpoint', `No endpoint called ${name} was found.`, x.map.endpoints.length ? `Endpoints: ${x.map.endpoints.map((e) => e.name).join(', ')}` : 'Add one with codz api add.');
|
|
144
|
+
const text = x.read(ep.file) ?? '';
|
|
145
|
+
const iface = /\binterface\s+(\w+)/.exec(text)?.[1];
|
|
146
|
+
const fun = kotlinFun(text, ep.name);
|
|
147
|
+
if (!iface || !fun)
|
|
148
|
+
throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
|
|
149
|
+
return { ep, iface, ifaceClass: `${packageOf(x.srcDir, dirOf(ep.file))}.${iface}`, params: fun.params, returnType: fun.returnType };
|
|
150
|
+
}
|
|
151
|
+
/** How the ViewModel gets the API interface, per DI style. */
|
|
152
|
+
export function injection(x, a, vm, vmPkg, needsHandle) {
|
|
153
|
+
const api = `private val api: ${a.iface}`;
|
|
154
|
+
const handle = needsHandle ? ', savedStateHandle: SavedStateHandle' : '';
|
|
155
|
+
const handleImports = needsHandle ? ['androidx.lifecycle.SavedStateHandle'] : [];
|
|
156
|
+
const di = x.c.di;
|
|
157
|
+
if (di === 'hilt') {
|
|
158
|
+
return {
|
|
159
|
+
header: ['@HiltViewModel', `class ${vm} @Inject constructor(${api}${handle}) : ViewModel() {`],
|
|
160
|
+
imports: ['dagger.hilt.android.lifecycle.HiltViewModel', 'javax.inject.Inject', ...handleImports],
|
|
161
|
+
companion: [],
|
|
162
|
+
screenDefault: 'hiltViewModel()',
|
|
163
|
+
screenImports: ['androidx.hilt.navigation.compose.hiltViewModel'],
|
|
164
|
+
activity: { annotations: ['@AndroidEntryPoint'], delegate: 'by viewModels()', imports: ['dagger.hilt.android.AndroidEntryPoint', 'androidx.activity.viewModels'] },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (di === 'koin') {
|
|
168
|
+
registerKoinViewModel(x, vm, `${vmPkg}.${vm}`);
|
|
169
|
+
return { header: [`class ${vm}(${api}${handle}) : ViewModel() {`], imports: handleImports, companion: [], screenDefault: 'koinViewModel()', screenImports: ['org.koin.androidx.compose.koinViewModel'], activity: { annotations: [], delegate: 'by viewModel()', imports: ['org.koin.androidx.viewmodel.ext.android.viewModel'] } };
|
|
170
|
+
}
|
|
171
|
+
if (di === 'manual') {
|
|
172
|
+
const locatorText = x.c.diFile ? x.read(x.c.diFile) : null;
|
|
173
|
+
const locator = locatorText ? /\bobject\s+(\w+)/.exec(locatorText)?.[1] : undefined;
|
|
174
|
+
const prop = locatorText ? new RegExp(`\\bval\\s+(\\w+)\\s*:\\s*${a.iface}\\b`).exec(locatorText)?.[1] : undefined;
|
|
175
|
+
if (!locator || !prop)
|
|
176
|
+
throw new ToolError('not_supported', `${a.iface} is not in the service locator${x.c.diFile ? ` (${x.c.diFile})` : ''}, so the ViewModel cannot get it.`, `Add val ${a.iface[0].toLowerCase()}${a.iface.slice(1)}: ${a.iface} by lazy { retrofit.create(${a.iface}::class.java) } to it.`);
|
|
177
|
+
return {
|
|
178
|
+
header: [`class ${vm}(${api}${handle}) : ViewModel() {`],
|
|
179
|
+
imports: [...handleImports, 'androidx.lifecycle.ViewModelProvider', 'androidx.lifecycle.viewmodel.initializer', 'androidx.lifecycle.viewmodel.viewModelFactory', ...(needsHandle ? ['androidx.lifecycle.createSavedStateHandle'] : []), classOfFile(x.srcDir, x.c.diFile)],
|
|
180
|
+
companion: ['', ' companion object {', ` val Factory: ViewModelProvider.Factory = viewModelFactory {`, ` initializer { ${vm}(${locator}.${prop}${needsHandle ? ', createSavedStateHandle()' : ''}) }`, ' }', ' }'],
|
|
181
|
+
screenDefault: `viewModel(factory = ${vm}.Factory)`,
|
|
182
|
+
screenImports: ['androidx.lifecycle.viewmodel.compose.viewModel'],
|
|
183
|
+
activity: { annotations: [], delegate: `by viewModels { ${vm}.Factory }`, imports: ['androidx.activity.viewModels'] },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
throw new ToolError('not_supported', `screen add needs a way to get ${a.iface} into a ViewModel (Hilt, Koin or a service locator); this project registers it with "${di || 'nothing'}".`, `Use codz route add ${x.map.endpoints[0]?.name ? '<name>' : ''} for a screen without data.`);
|
|
187
|
+
}
|
|
188
|
+
function registerKoinViewModel(x, vm, vmClass) {
|
|
189
|
+
const di = x.c.diFile;
|
|
190
|
+
const text = di ? x.read(di) : null;
|
|
191
|
+
if (!di || text === null)
|
|
192
|
+
return void x.warnings.push(`No Koin module was found; add viewModelOf(::${vm}) to one.`);
|
|
193
|
+
if (new RegExp(`::${vm}\\b`).test(text))
|
|
194
|
+
return;
|
|
195
|
+
const m = /\bmodule\s*\{/.exec(text);
|
|
196
|
+
if (!m)
|
|
197
|
+
return void x.warnings.push(`Could not find module { } in ${di}; add viewModelOf(::${vm}).`);
|
|
198
|
+
const close = matchBrace(text, m.index + m[0].length - 1, 'dart');
|
|
199
|
+
let t = `${text.slice(0, close).replace(/\s*$/, '')}\n viewModelOf(::${vm})\n${text.slice(close)}`;
|
|
200
|
+
t = addKotlinImports(t, ['org.koin.core.module.dsl.viewModelOf', vmClass], /^package\s+([\w.]+)/m.exec(t)?.[1] ?? '');
|
|
201
|
+
x.files.set(di, t);
|
|
202
|
+
x.summary.push(`Update ${di}: viewModelOf(::${vm})`);
|
|
203
|
+
}
|
|
204
|
+
/** ui/UiState.kt, shared by every screen codz writes. */
|
|
205
|
+
export function ensureUiState(x, screensDir) {
|
|
206
|
+
const uiDir = dirOf(screensDir);
|
|
207
|
+
const file = `${uiDir}/UiState.kt`;
|
|
208
|
+
const pkg = packageOf(x.srcDir, uiDir);
|
|
209
|
+
if (x.read(file) === null) {
|
|
210
|
+
x.files.set(file, `package ${pkg}\n\n/** What a screen shows while it loads data, when it has it, and when loading failed. */\nsealed interface UiState<out T> {\n data object Loading : UiState<Nothing>\n\n data class Success<T>(val data: T) : UiState<T>\n\n data class Error(val message: String) : UiState<Nothing>\n}\n`);
|
|
211
|
+
x.summary.push(`Create ${file}: UiState (Loading, Success, Error)`);
|
|
212
|
+
}
|
|
213
|
+
return `${pkg}.UiState`;
|
|
214
|
+
}
|
|
215
|
+
export const access = (v, jsonName) => `${v}.${kotlinName(jsonName)}`;
|
|
216
|
+
export function planAndroidScreen(root, map, opts) {
|
|
217
|
+
if (map.conventions.ui === 'views')
|
|
218
|
+
return planViewsScreen(root, map, opts);
|
|
219
|
+
if (!opts.api)
|
|
220
|
+
return planAndroidRoute(root, map, opts);
|
|
221
|
+
const x = context(root, map);
|
|
222
|
+
const a = apiRef(x, opts.api);
|
|
223
|
+
if (opts.form)
|
|
224
|
+
return planAndroidForm(x, opts, a);
|
|
225
|
+
if (a.ep.method !== 'GET')
|
|
226
|
+
throw new ToolError('screen_needs_get', `${a.ep.name} is a ${a.ep.method} endpoint; a screen loads data with a GET.`, 'For a form that sends it, add --form.');
|
|
227
|
+
const n = names(opts.name);
|
|
228
|
+
const { route, params } = routeOf(opts.name, opts.path);
|
|
229
|
+
const required = a.params.filter((p) => !p.optional && p.annotation !== 'Body');
|
|
230
|
+
const missing = required.filter((p) => !params.includes(p.name.replace(/`/g, '')));
|
|
231
|
+
if (missing.length)
|
|
232
|
+
throw new ToolError('screen_args', `${a.ep.name}() needs ${missing.map((p) => p.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.base)}/:${missing[0].name}`);
|
|
233
|
+
const screensDir = x.c.screensFolder || `${x.srcDir}/${x.c.namespace.replace(/\./g, '/')}/ui/screens`;
|
|
234
|
+
const screenFile = fileOf(screensDir, n.screen);
|
|
235
|
+
const vmFile = fileOf(screensDir, n.vm);
|
|
236
|
+
for (const f of [screenFile, vmFile])
|
|
237
|
+
if (x.read(f) !== null)
|
|
238
|
+
throw new ToolError('route_exists', `${f} already exists.`);
|
|
239
|
+
const pkg = packageOf(x.srcDir, screensDir);
|
|
240
|
+
const uiState = ensureUiState(x, screensDir);
|
|
241
|
+
const T = a.returnType;
|
|
242
|
+
const shape = responseShape(T, map);
|
|
243
|
+
const model = shape.model;
|
|
244
|
+
const modelImports = model ? [classOfFile(x.srcDir, model.file)] : [];
|
|
245
|
+
const needsHandle = params.length > 0;
|
|
246
|
+
const inj = injection(x, a, n.vm, pkg, needsHandle);
|
|
247
|
+
const args = required.map((p) => {
|
|
248
|
+
const v = p.name.replace(/`/g, '');
|
|
249
|
+
return /^(Int|Long)$/.test(p.type) ? `${v}.to${p.type}()` : v;
|
|
250
|
+
});
|
|
251
|
+
// ViewModel.
|
|
252
|
+
x.files.set(vmFile, dataViewModel(x, a, n.vm, pkg, params, args, inj, uiState, T, modelImports));
|
|
253
|
+
x.summary.push(`Create ${vmFile}: ${n.vm} loads ${a.iface}.${a.ep.name}()${params.length ? ` with ${params.join(', ')} from the route` : ''}`);
|
|
254
|
+
// Screen.
|
|
255
|
+
const tf = titleField(model);
|
|
256
|
+
const success = shape.list
|
|
257
|
+
? [
|
|
258
|
+
' is UiState.Success -> if (s.data.isEmpty()) {',
|
|
259
|
+
' Text("Nothing here yet.", Modifier.align(Alignment.Center))',
|
|
260
|
+
' } else {',
|
|
261
|
+
' LazyColumn(Modifier.fillMaxSize()) {',
|
|
262
|
+
' items(s.data) { item ->',
|
|
263
|
+
` Text(${tf ? `${access('item', tf)}.toString()` : 'item.toString()'}, Modifier.fillMaxWidth().padding(16.dp))`,
|
|
264
|
+
' HorizontalDivider()',
|
|
265
|
+
' }',
|
|
266
|
+
' }',
|
|
267
|
+
' }',
|
|
268
|
+
]
|
|
269
|
+
: [
|
|
270
|
+
' is UiState.Success -> Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {',
|
|
271
|
+
...(model?.fields.length
|
|
272
|
+
? model.fields.flatMap((f) => [` Text("${f.name}", style = MaterialTheme.typography.labelMedium)`, ` Text(${access('s.data', f.name)}?.toString().orEmpty(), Modifier.padding(bottom = 12.dp))`])
|
|
273
|
+
: [' Text(s.data.toString())']),
|
|
274
|
+
' }',
|
|
275
|
+
];
|
|
276
|
+
const screen = [
|
|
277
|
+
`package ${pkg}`,
|
|
278
|
+
'',
|
|
279
|
+
'@Composable',
|
|
280
|
+
`fun ${n.screen}(viewModel: ${n.vm} = ${inj.screenDefault}) {`,
|
|
281
|
+
' val state by viewModel.state.collectAsState()',
|
|
282
|
+
' Scaffold { padding ->',
|
|
283
|
+
' Box(Modifier.fillMaxSize().padding(padding)) {',
|
|
284
|
+
' when (val s = state) {',
|
|
285
|
+
' is UiState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))',
|
|
286
|
+
' is UiState.Error -> Column(Modifier.align(Alignment.Center).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {',
|
|
287
|
+
' Text(s.message, color = MaterialTheme.colorScheme.error)',
|
|
288
|
+
' Button(onClick = viewModel::load) { Text("Retry") }',
|
|
289
|
+
' }',
|
|
290
|
+
...success,
|
|
291
|
+
' }',
|
|
292
|
+
' }',
|
|
293
|
+
' }',
|
|
294
|
+
'}',
|
|
295
|
+
'',
|
|
296
|
+
].join('\n');
|
|
297
|
+
const composeImports = [
|
|
298
|
+
'androidx.compose.foundation.layout.Box',
|
|
299
|
+
'androidx.compose.foundation.layout.Column',
|
|
300
|
+
'androidx.compose.foundation.layout.fillMaxSize',
|
|
301
|
+
'androidx.compose.foundation.layout.padding',
|
|
302
|
+
'androidx.compose.material3.Button',
|
|
303
|
+
'androidx.compose.material3.CircularProgressIndicator',
|
|
304
|
+
'androidx.compose.material3.MaterialTheme',
|
|
305
|
+
'androidx.compose.material3.Scaffold',
|
|
306
|
+
'androidx.compose.material3.Text',
|
|
307
|
+
'androidx.compose.runtime.Composable',
|
|
308
|
+
'androidx.compose.runtime.collectAsState',
|
|
309
|
+
'androidx.compose.runtime.getValue',
|
|
310
|
+
'androidx.compose.ui.Alignment',
|
|
311
|
+
'androidx.compose.ui.Modifier',
|
|
312
|
+
'androidx.compose.ui.unit.dp',
|
|
313
|
+
...(shape.list ? ['androidx.compose.foundation.layout.fillMaxWidth', 'androidx.compose.foundation.lazy.LazyColumn', 'androidx.compose.foundation.lazy.items', 'androidx.compose.material3.HorizontalDivider'] : ['androidx.compose.foundation.rememberScrollState', 'androidx.compose.foundation.verticalScroll']),
|
|
314
|
+
];
|
|
315
|
+
x.files.set(screenFile, addKotlinImports(screen, [...composeImports, uiState, ...inj.screenImports], pkg));
|
|
316
|
+
x.summary.push(`Create ${screenFile}: ${n.screen} (loading, error with Retry, ${shape.list ? 'LazyColumn' : 'details'})`);
|
|
317
|
+
registerRoute(x, route, `${n.screen}()`, `${pkg}.${n.screen}`, params, false);
|
|
318
|
+
return finish(x, 'screen add', navHint(route, params));
|
|
319
|
+
}
|
|
320
|
+
function planAndroidForm(x, opts, a) {
|
|
321
|
+
if (!['POST', 'PUT', 'PATCH'].includes(a.ep.method))
|
|
322
|
+
throw new ToolError('form_needs_body', `${a.ep.name} is a ${a.ep.method} endpoint; a form sends a request body (POST, PUT or PATCH).`);
|
|
323
|
+
const body = a.params.find((p) => p.annotation === 'Body');
|
|
324
|
+
if (!body)
|
|
325
|
+
throw new ToolError('form_needs_body', `${a.ep.name}() takes no @Body, so there is nothing for a form to send.`);
|
|
326
|
+
const bodyModel = x.map.models.find((m) => m.name === body.type.replace(/\?$/, ''));
|
|
327
|
+
if (!bodyModel)
|
|
328
|
+
throw new ToolError('form_needs_body', `The request type ${body.type} was not found among the data classes.`);
|
|
329
|
+
const n = names(opts.name);
|
|
330
|
+
const { route, params } = routeOf(opts.name, opts.path);
|
|
331
|
+
const required = a.params.filter((p) => !p.optional && p.annotation !== 'Body');
|
|
332
|
+
const missing = required.filter((p) => !params.includes(p.name.replace(/`/g, '')));
|
|
333
|
+
if (missing.length)
|
|
334
|
+
throw new ToolError('screen_args', `${a.ep.name}() needs ${missing.map((p) => p.name).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${kebab(n.base)}/:${missing[0].name}`);
|
|
335
|
+
const screensDir = x.c.screensFolder || `${x.srcDir}/${x.c.namespace.replace(/\./g, '/')}/ui/screens`;
|
|
336
|
+
const screenFile = fileOf(screensDir, n.screen);
|
|
337
|
+
const vmFile = fileOf(screensDir, n.vm);
|
|
338
|
+
for (const f of [screenFile, vmFile])
|
|
339
|
+
if (x.read(f) !== null)
|
|
340
|
+
throw new ToolError('route_exists', `${f} already exists.`);
|
|
341
|
+
const pkg = packageOf(x.srcDir, screensDir);
|
|
342
|
+
const uiState = ensureUiState(x, screensDir);
|
|
343
|
+
const R = a.returnType;
|
|
344
|
+
const inj = injection(x, a, n.vm, pkg, params.length > 0);
|
|
345
|
+
const args = [...required.map((p) => (/^(Int|Long)$/.test(p.type) ? `${p.name}.to${p.type}()` : p.name)), 'body'];
|
|
346
|
+
const bodyClass = classOfFile(x.srcDir, bodyModel.file);
|
|
347
|
+
const result = responseShape(R, x.map).model;
|
|
348
|
+
const prefill = androidPrefill(x, a, opts.load, bodyModel.fields.map((f) => f.name), params);
|
|
349
|
+
x.files.set(vmFile, formViewModel(x, a, n.vm, pkg, params, args, inj, uiState, R, bodyModel.name, bodyClass, result ? [classOfFile(x.srcDir, result.file)] : [], prefill));
|
|
350
|
+
x.summary.push(`Create ${vmFile}: ${n.vm}.submit() sends ${a.iface}.${a.ep.name}()${prefill ? `; the form starts with ${prefill.call.replace(/^api\./, `${a.iface}.`)}` : ''}`);
|
|
351
|
+
// One state variable and one input per field.
|
|
352
|
+
const fields = bodyModel.fields.map((f) => {
|
|
353
|
+
const v = kotlinName(f.name).replace(/`/g, '');
|
|
354
|
+
const t = f.type.replace(/\?$/, '');
|
|
355
|
+
return { json: f.name, prop: kotlinName(f.name), v, t, nullable: f.nullable, bool: t === 'Boolean', num: /^(Int|Long|Double|Float)$/.test(t), text: t === 'String' };
|
|
356
|
+
});
|
|
357
|
+
const unsupported = fields.filter((f) => !f.bool && !f.num && !f.text);
|
|
358
|
+
if (unsupported.length)
|
|
359
|
+
x.warnings.push(`The form has no input for ${unsupported.map((f) => `${f.json} (${f.t})`).join(', ')}; ${unsupported.some((f) => !f.nullable) ? 'fill them in where the body is built' : 'they are sent as null'}.`);
|
|
360
|
+
const stateLines = fields.filter((f) => f.bool || f.num || f.text).map((f) => ` var ${f.v} by remember { mutableStateOf(${f.bool ? 'false' : '""'}) }`);
|
|
361
|
+
const value = (f) => {
|
|
362
|
+
if (f.bool || f.text)
|
|
363
|
+
return f.text && f.nullable ? `${f.v}.ifBlank { null }` : f.v;
|
|
364
|
+
if (f.num)
|
|
365
|
+
return `${f.v}.to${f.t}OrNull()${f.nullable ? '' : ` ?: 0${f.t === 'Double' ? '.0' : f.t === 'Float' ? 'f' : f.t === 'Long' ? 'L' : ''}`}`;
|
|
366
|
+
return f.nullable ? 'null' : `TODO("${f.json}")`;
|
|
367
|
+
};
|
|
368
|
+
const inputs = fields.flatMap((f) => {
|
|
369
|
+
if (f.bool)
|
|
370
|
+
return [` Row(verticalAlignment = Alignment.CenterVertically) {`, ` Switch(checked = ${f.v}, onCheckedChange = { ${f.v} = it })`, ` Text("${f.json}", Modifier.padding(start = 8.dp))`, ' }'];
|
|
371
|
+
if (f.num)
|
|
372
|
+
return [` OutlinedTextField(${f.v}, { ${f.v} = it }, label = { Text("${f.json}") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth())`];
|
|
373
|
+
if (f.text)
|
|
374
|
+
return [` OutlinedTextField(${f.v}, { ${f.v} = it }, label = { Text("${f.json}") }, modifier = Modifier.fillMaxWidth())`];
|
|
375
|
+
return [];
|
|
376
|
+
});
|
|
377
|
+
const screen = [
|
|
378
|
+
`package ${pkg}`,
|
|
379
|
+
'',
|
|
380
|
+
'@Composable',
|
|
381
|
+
`fun ${n.screen}(viewModel: ${n.vm} = ${inj.screenDefault}, onDone: () -> Unit = {}) {`,
|
|
382
|
+
' val state by viewModel.state.collectAsState()',
|
|
383
|
+
...stateLines,
|
|
384
|
+
...(prefill
|
|
385
|
+
? [
|
|
386
|
+
' val initial by viewModel.initial.collectAsState()',
|
|
387
|
+
' LaunchedEffect(initial) {',
|
|
388
|
+
' initial?.let {',
|
|
389
|
+
...fields
|
|
390
|
+
.filter((f) => (f.bool || f.num || f.text) && prefill.shared.has(f.json) && prefill.model.fields.find((m) => m.name === f.json).type.replace(/\?$/, '') === f.t)
|
|
391
|
+
.map((f) => ` ${f.v} = ${ktInitial(f.bool ? 'bool' : f.num ? 'num' : 'text', `it.${kotlinName(f.json)}`, prefill.model.fields.find((m) => m.name === f.json).nullable)}`),
|
|
392
|
+
' }',
|
|
393
|
+
' }',
|
|
394
|
+
]
|
|
395
|
+
: []),
|
|
396
|
+
' LaunchedEffect(state) {',
|
|
397
|
+
' if (state is UiState.Success) onDone()',
|
|
398
|
+
' }',
|
|
399
|
+
' Scaffold { padding ->',
|
|
400
|
+
' Column(',
|
|
401
|
+
' Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp),',
|
|
402
|
+
' verticalArrangement = Arrangement.spacedBy(12.dp),',
|
|
403
|
+
' ) {',
|
|
404
|
+
` Text("${n.title}", style = MaterialTheme.typography.headlineSmall)`,
|
|
405
|
+
...inputs,
|
|
406
|
+
' (state as? UiState.Error)?.let { Text(it.message, color = MaterialTheme.colorScheme.error) }',
|
|
407
|
+
' Button(',
|
|
408
|
+
' onClick = {',
|
|
409
|
+
' viewModel.submit(',
|
|
410
|
+
` ${bodyModel.name}(`,
|
|
411
|
+
...fields.map((f) => ` ${f.prop} = ${value(f)},`),
|
|
412
|
+
' ),',
|
|
413
|
+
' )',
|
|
414
|
+
' },',
|
|
415
|
+
' enabled = state !is UiState.Loading,',
|
|
416
|
+
' modifier = Modifier.fillMaxWidth(),',
|
|
417
|
+
' ) {',
|
|
418
|
+
' if (state is UiState.Loading) CircularProgressIndicator(Modifier.size(18.dp)) else Text("Save")',
|
|
419
|
+
' }',
|
|
420
|
+
' }',
|
|
421
|
+
' }',
|
|
422
|
+
'}',
|
|
423
|
+
'',
|
|
424
|
+
].join('\n');
|
|
425
|
+
const imports = [
|
|
426
|
+
'androidx.compose.foundation.layout.Arrangement',
|
|
427
|
+
'androidx.compose.foundation.layout.Column',
|
|
428
|
+
'androidx.compose.foundation.layout.fillMaxSize',
|
|
429
|
+
'androidx.compose.foundation.layout.fillMaxWidth',
|
|
430
|
+
'androidx.compose.foundation.layout.padding',
|
|
431
|
+
'androidx.compose.foundation.layout.size',
|
|
432
|
+
'androidx.compose.foundation.rememberScrollState',
|
|
433
|
+
'androidx.compose.foundation.verticalScroll',
|
|
434
|
+
'androidx.compose.material3.Button',
|
|
435
|
+
'androidx.compose.material3.CircularProgressIndicator',
|
|
436
|
+
'androidx.compose.material3.MaterialTheme',
|
|
437
|
+
'androidx.compose.material3.Scaffold',
|
|
438
|
+
'androidx.compose.material3.Text',
|
|
439
|
+
'androidx.compose.runtime.Composable',
|
|
440
|
+
'androidx.compose.runtime.LaunchedEffect',
|
|
441
|
+
'androidx.compose.runtime.collectAsState',
|
|
442
|
+
'androidx.compose.runtime.getValue',
|
|
443
|
+
'androidx.compose.ui.Modifier',
|
|
444
|
+
'androidx.compose.ui.unit.dp',
|
|
445
|
+
...(stateLines.length ? ['androidx.compose.runtime.mutableStateOf', 'androidx.compose.runtime.remember', 'androidx.compose.runtime.setValue'] : []),
|
|
446
|
+
...(fields.some((f) => f.num || f.text) ? ['androidx.compose.material3.OutlinedTextField'] : []),
|
|
447
|
+
...(fields.some((f) => f.num) ? ['androidx.compose.foundation.text.KeyboardOptions', 'androidx.compose.ui.text.input.KeyboardType'] : []),
|
|
448
|
+
...(fields.some((f) => f.bool) ? ['androidx.compose.foundation.layout.Row', 'androidx.compose.material3.Switch', 'androidx.compose.ui.Alignment'] : []),
|
|
449
|
+
uiState,
|
|
450
|
+
bodyClass,
|
|
451
|
+
...inj.screenImports,
|
|
452
|
+
];
|
|
453
|
+
x.files.set(screenFile, addKotlinImports(screen, imports, pkg));
|
|
454
|
+
x.summary.push(`Create ${screenFile}: ${n.screen} with ${fields.length} field${fields.length === 1 ? '' : 's'} for ${bodyModel.name}`);
|
|
455
|
+
registerRoute(x, route, `${n.screen}()`, `${pkg}.${n.screen}`, params, false);
|
|
456
|
+
return finish(x, 'screen add', navHint(route, params));
|
|
457
|
+
}
|
|
458
|
+
/** The ViewModel of a data screen (Compose and Views share it): loads the endpoint into StateFlow<UiState<T>>. */
|
|
459
|
+
export function dataViewModel(x, a, vmName, pkg, params, args, inj, uiState, T, modelImports) {
|
|
460
|
+
const vm = [
|
|
461
|
+
`package ${pkg}`,
|
|
462
|
+
'',
|
|
463
|
+
...inj.header,
|
|
464
|
+
...params.map((p) => ` private val ${p}: String = checkNotNull(savedStateHandle["${p}"])`),
|
|
465
|
+
...(params.length ? [''] : []),
|
|
466
|
+
` private val _state = MutableStateFlow<UiState<${T}>>(UiState.Loading)`,
|
|
467
|
+
` val state: StateFlow<UiState<${T}>> = _state.asStateFlow()`,
|
|
468
|
+
'',
|
|
469
|
+
' init {',
|
|
470
|
+
' load()',
|
|
471
|
+
' }',
|
|
472
|
+
'',
|
|
473
|
+
' fun load() {',
|
|
474
|
+
' viewModelScope.launch {',
|
|
475
|
+
' _state.value = UiState.Loading',
|
|
476
|
+
' _state.value = try {',
|
|
477
|
+
` UiState.Success(api.${a.ep.name}(${args.join(', ')}))`,
|
|
478
|
+
' } catch (e: Exception) {',
|
|
479
|
+
' UiState.Error(e.message ?: "Something went wrong")',
|
|
480
|
+
' }',
|
|
481
|
+
' }',
|
|
482
|
+
' }',
|
|
483
|
+
...inj.companion,
|
|
484
|
+
'}',
|
|
485
|
+
'',
|
|
486
|
+
].join('\n');
|
|
487
|
+
return addKotlinImports(vm, ['androidx.lifecycle.ViewModel', 'androidx.lifecycle.viewModelScope', 'kotlinx.coroutines.flow.MutableStateFlow', 'kotlinx.coroutines.flow.StateFlow', 'kotlinx.coroutines.flow.asStateFlow', 'kotlinx.coroutines.launch', a.ifaceClass, uiState, ...modelImports, ...inj.imports], pkg);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Edit forms: the detail endpoint (feature add passes it as load) when it is on the
|
|
491
|
+
* same Retrofit interface and takes only route parameters, so the form ViewModel
|
|
492
|
+
* can load the current values. null when the form starts empty.
|
|
493
|
+
*/
|
|
494
|
+
export function androidPrefill(x, a, loadName, bodyFields, params) {
|
|
495
|
+
const pre = prefillFor(x.root, x.map, loadName, bodyFields, params, 'kt');
|
|
496
|
+
if (!pre)
|
|
497
|
+
return null;
|
|
498
|
+
let load;
|
|
499
|
+
try {
|
|
500
|
+
load = apiRef(x, pre.ep.name);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
if (load.ifaceClass !== a.ifaceClass)
|
|
506
|
+
return null;
|
|
507
|
+
const required = load.params.filter((p) => !p.optional);
|
|
508
|
+
if (!required.every((p) => params.includes(p.name.replace(/`/g, ''))))
|
|
509
|
+
return null;
|
|
510
|
+
const args = required.map((p) => (/^(Int|Long)$/.test(p.type) ? `${p.name}.to${p.type}()` : p.name));
|
|
511
|
+
return { call: `api.${pre.ep.name}(${args.join(', ')})`, model: pre.model, modelClass: classOfFile(x.srcDir, pre.model.file), shared: pre.shared };
|
|
512
|
+
}
|
|
513
|
+
/** Kotlin: the text (or Boolean) an input starts with, from the loaded object it. */
|
|
514
|
+
export function ktInitial(kind, access, nullable) {
|
|
515
|
+
if (kind === 'bool')
|
|
516
|
+
return nullable ? `${access} ?: false` : access;
|
|
517
|
+
if (kind === 'num')
|
|
518
|
+
return nullable ? `${access}?.toString().orEmpty()` : `${access}.toString()`;
|
|
519
|
+
return nullable ? `${access}.orEmpty()` : access;
|
|
520
|
+
}
|
|
521
|
+
export function formViewModel(x, a, vmName, pkg, params, args, inj, uiState, R, bodyName, bodyClass, resultImports, prefill) {
|
|
522
|
+
const vm = [
|
|
523
|
+
`package ${pkg}`,
|
|
524
|
+
'',
|
|
525
|
+
...inj.header,
|
|
526
|
+
...params.map((p) => ` private val ${p}: String = checkNotNull(savedStateHandle["${p}"])`),
|
|
527
|
+
...(params.length ? [''] : []),
|
|
528
|
+
` /** null until the form is sent. */`,
|
|
529
|
+
` private val _state = MutableStateFlow<UiState<${R}>?>(null)`,
|
|
530
|
+
` val state: StateFlow<UiState<${R}>?> = _state.asStateFlow()`,
|
|
531
|
+
...(prefill
|
|
532
|
+
? [
|
|
533
|
+
'',
|
|
534
|
+
' /** The current values, loaded once to fill the form (null until loaded, or when loading failed). */',
|
|
535
|
+
` private val _initial = MutableStateFlow<${prefill.model.name}?>(null)`,
|
|
536
|
+
` val initial: StateFlow<${prefill.model.name}?> = _initial.asStateFlow()`,
|
|
537
|
+
'',
|
|
538
|
+
' init {',
|
|
539
|
+
' viewModelScope.launch {',
|
|
540
|
+
` _initial.value = try { ${prefill.call} } catch (e: Exception) { null }`,
|
|
541
|
+
' }',
|
|
542
|
+
' }',
|
|
543
|
+
]
|
|
544
|
+
: []),
|
|
545
|
+
'',
|
|
546
|
+
` fun submit(body: ${bodyName}) {`,
|
|
547
|
+
' viewModelScope.launch {',
|
|
548
|
+
' _state.value = UiState.Loading',
|
|
549
|
+
' _state.value = try {',
|
|
550
|
+
` UiState.Success(api.${a.ep.name}(${args.join(', ')}))`,
|
|
551
|
+
' } catch (e: Exception) {',
|
|
552
|
+
' UiState.Error(e.message ?: "Something went wrong")',
|
|
553
|
+
' }',
|
|
554
|
+
' }',
|
|
555
|
+
' }',
|
|
556
|
+
...inj.companion,
|
|
557
|
+
'}',
|
|
558
|
+
'',
|
|
559
|
+
].join('\n');
|
|
560
|
+
return addKotlinImports(vm, ['androidx.lifecycle.ViewModel', 'androidx.lifecycle.viewModelScope', 'kotlinx.coroutines.flow.MutableStateFlow', 'kotlinx.coroutines.flow.StateFlow', 'kotlinx.coroutines.flow.asStateFlow', 'kotlinx.coroutines.launch', a.ifaceClass, uiState, bodyClass, ...resultImports, ...(prefill ? [prefill.modelClass] : []), ...inj.imports], pkg);
|
|
561
|
+
}
|