morphkit-cli 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/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/index.js +30940 -0
- package/package.json +60 -0
- package/templates/swift/app-entry.swift.ts +46 -0
- package/templates/swift/model.swift.ts +72 -0
- package/templates/swiftui/navigation.swift.ts +118 -0
- package/templates/swiftui/networking.swift.ts +145 -0
- package/templates/swiftui/view.swift.ts +230 -0
- package/templates/xcode-project/assets.ts +112 -0
- package/templates/xcode-project/info-plist.ts +115 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "morphkit-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Semantic AI agent that converts TypeScript/React web apps to native SwiftUI iOS apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"morphkit": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"templates",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "bun run src/index.ts",
|
|
17
|
+
"build": "bun build src/index.ts --outdir dist --target node",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"analyze": "bun run src/index.ts analyze",
|
|
21
|
+
"generate": "bun run src/index.ts generate",
|
|
22
|
+
"prepublishOnly": "bun build src/index.ts --outdir dist --target node --minify"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"openai": "^4.0.0",
|
|
26
|
+
"ts-morph": "^24.0.0",
|
|
27
|
+
"commander": "^13.0.0",
|
|
28
|
+
"chalk": "^5.4.0",
|
|
29
|
+
"ora": "^8.0.0",
|
|
30
|
+
"simple-git": "^3.27.0",
|
|
31
|
+
"fast-glob": "^3.3.0",
|
|
32
|
+
"zod": "^3.24.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest",
|
|
36
|
+
"typescript": "^5.7.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"typescript",
|
|
40
|
+
"swift",
|
|
41
|
+
"swiftui",
|
|
42
|
+
"ios",
|
|
43
|
+
"react",
|
|
44
|
+
"nextjs",
|
|
45
|
+
"code-generation",
|
|
46
|
+
"ai",
|
|
47
|
+
"mobile"
|
|
48
|
+
],
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"author": "AshlrAI <hello@ashlr.ai>",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "https://github.com/ashlrai/morphkit.git"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://morphkit.dev",
|
|
56
|
+
"bugs": "https://github.com/ashlrai/morphkit/issues",
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Template for generating the main App entry point
|
|
2
|
+
|
|
3
|
+
export function generateAppEntry(appName: string): string {
|
|
4
|
+
return `// Generated by Morphkit
|
|
5
|
+
import SwiftUI
|
|
6
|
+
|
|
7
|
+
@main
|
|
8
|
+
struct ${appName}App: App {
|
|
9
|
+
var body: some Scene {
|
|
10
|
+
WindowGroup {
|
|
11
|
+
ContentView()
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateAppEntryWithState(appName: string, storeNames: string[]): string {
|
|
19
|
+
const storeDeclarations = storeNames
|
|
20
|
+
.map(name => ` @State private var ${camelCase(name)} = ${name}()`)
|
|
21
|
+
.join('\n');
|
|
22
|
+
|
|
23
|
+
const environmentModifiers = storeNames
|
|
24
|
+
.map(name => ` .environment(${camelCase(name)})`)
|
|
25
|
+
.join('\n');
|
|
26
|
+
|
|
27
|
+
return `// Generated by Morphkit
|
|
28
|
+
import SwiftUI
|
|
29
|
+
|
|
30
|
+
@main
|
|
31
|
+
struct ${appName}App: App {
|
|
32
|
+
${storeDeclarations}
|
|
33
|
+
|
|
34
|
+
var body: some Scene {
|
|
35
|
+
WindowGroup {
|
|
36
|
+
ContentView()
|
|
37
|
+
${environmentModifiers}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function camelCase(str: string): string {
|
|
45
|
+
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Template for generating Swift data models
|
|
2
|
+
|
|
3
|
+
export interface SwiftProperty {
|
|
4
|
+
name: string;
|
|
5
|
+
type: string;
|
|
6
|
+
optional: boolean;
|
|
7
|
+
defaultValue?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SwiftModelConfig {
|
|
11
|
+
name: string;
|
|
12
|
+
properties: SwiftProperty[];
|
|
13
|
+
protocols: string[];
|
|
14
|
+
hasId: boolean;
|
|
15
|
+
sourceFile: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateSwiftModel(config: SwiftModelConfig): string {
|
|
19
|
+
const protocols = config.protocols.length > 0
|
|
20
|
+
? `: ${config.protocols.join(', ')}`
|
|
21
|
+
: '';
|
|
22
|
+
|
|
23
|
+
const properties = config.properties
|
|
24
|
+
.map(p => {
|
|
25
|
+
const type = p.optional ? `${p.type}?` : p.type;
|
|
26
|
+
const defaultVal = p.defaultValue ? ` = ${p.defaultValue}` : '';
|
|
27
|
+
return ` var ${p.name}: ${type}${defaultVal}`;
|
|
28
|
+
})
|
|
29
|
+
.join('\n');
|
|
30
|
+
|
|
31
|
+
// Generate CodingKeys if any property names need mapping
|
|
32
|
+
const needsCodingKeys = config.properties.some(p => p.name.includes('_') || p.name !== camelCase(p.name));
|
|
33
|
+
const codingKeys = needsCodingKeys
|
|
34
|
+
? generateCodingKeys(config.properties)
|
|
35
|
+
: '';
|
|
36
|
+
|
|
37
|
+
return `// Generated by Morphkit from: ${config.sourceFile}
|
|
38
|
+
import Foundation
|
|
39
|
+
|
|
40
|
+
struct ${config.name}${protocols} {
|
|
41
|
+
${properties}
|
|
42
|
+
}
|
|
43
|
+
${codingKeys}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function generateSwiftEnum(name: string, cases: string[], sourceFile: string): string {
|
|
47
|
+
const enumCases = cases.map(c => ` case ${camelCase(c)}`).join('\n');
|
|
48
|
+
|
|
49
|
+
return `// Generated by Morphkit from: ${sourceFile}
|
|
50
|
+
import Foundation
|
|
51
|
+
|
|
52
|
+
enum ${name}: String, Codable, CaseIterable {
|
|
53
|
+
${enumCases}
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function generateCodingKeys(properties: SwiftProperty[]): string {
|
|
59
|
+
const keys = properties
|
|
60
|
+
.map(p => ` case ${camelCase(p.name)} = "${p.name}"`)
|
|
61
|
+
.join('\n');
|
|
62
|
+
|
|
63
|
+
return `
|
|
64
|
+
enum CodingKeys: String, CodingKey {
|
|
65
|
+
${keys}
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function camelCase(str: string): string {
|
|
71
|
+
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
72
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Templates for generating navigation structure
|
|
2
|
+
|
|
3
|
+
export interface RouteDefinition {
|
|
4
|
+
name: string;
|
|
5
|
+
associatedValues?: { name: string; type: string }[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function generateRouterEnum(routes: RouteDefinition[]): string {
|
|
9
|
+
const cases = routes.map(r => {
|
|
10
|
+
if (r.associatedValues && r.associatedValues.length > 0) {
|
|
11
|
+
const values = r.associatedValues.map(v => `${v.name}: ${v.type}`).join(', ');
|
|
12
|
+
return ` case ${r.name}(${values})`;
|
|
13
|
+
}
|
|
14
|
+
return ` case ${r.name}`;
|
|
15
|
+
}).join('\n');
|
|
16
|
+
|
|
17
|
+
return `// Generated by Morphkit
|
|
18
|
+
import SwiftUI
|
|
19
|
+
|
|
20
|
+
enum AppRoute: Hashable {
|
|
21
|
+
${cases}
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function generateRouter(routes: RouteDefinition[]): string {
|
|
27
|
+
const destinationCases = routes.map(r => {
|
|
28
|
+
const viewName = pascalCase(r.name) + 'View';
|
|
29
|
+
if (r.associatedValues && r.associatedValues.length > 0) {
|
|
30
|
+
const bindings = r.associatedValues.map(v => `let ${v.name}`).join(', ');
|
|
31
|
+
const args = r.associatedValues.map(v => `${v.name}: ${v.name}`).join(', ');
|
|
32
|
+
return ` case .${r.name}(${bindings}):
|
|
33
|
+
${viewName}(${args})`;
|
|
34
|
+
}
|
|
35
|
+
return ` case .${r.name}:
|
|
36
|
+
${viewName}()`;
|
|
37
|
+
}).join('\n');
|
|
38
|
+
|
|
39
|
+
return `// Generated by Morphkit
|
|
40
|
+
import SwiftUI
|
|
41
|
+
|
|
42
|
+
@Observable
|
|
43
|
+
final class Router {
|
|
44
|
+
var path = NavigationPath()
|
|
45
|
+
|
|
46
|
+
func navigate(to route: AppRoute) {
|
|
47
|
+
path.append(route)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func goBack() {
|
|
51
|
+
guard !path.isEmpty else { return }
|
|
52
|
+
path.removeLast()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func popToRoot() {
|
|
56
|
+
path = NavigationPath()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
struct RouterDestination: ViewModifier {
|
|
61
|
+
func body(content: Content) -> some View {
|
|
62
|
+
content
|
|
63
|
+
.navigationDestination(for: AppRoute.self) { route in
|
|
64
|
+
switch route {
|
|
65
|
+
${destinationCases}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
extension View {
|
|
72
|
+
func withRouterDestinations() -> some View {
|
|
73
|
+
modifier(RouterDestination())
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function generateNavigationStackRoot(rootView: string): string {
|
|
80
|
+
return `NavigationStack(path: $router.path) {
|
|
81
|
+
${rootView}()
|
|
82
|
+
.withRouterDestinations()
|
|
83
|
+
}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function generateTabViewWithNavigation(
|
|
87
|
+
tabs: { name: string; icon: string; view: string }[],
|
|
88
|
+
): string {
|
|
89
|
+
const tabContent = tabs.map(t => ` Tab("${t.name}", systemImage: "${t.icon}") {
|
|
90
|
+
NavigationStack {
|
|
91
|
+
${t.view}()
|
|
92
|
+
.withRouterDestinations()
|
|
93
|
+
}
|
|
94
|
+
}`).join('\n');
|
|
95
|
+
|
|
96
|
+
return `// Generated by Morphkit
|
|
97
|
+
import SwiftUI
|
|
98
|
+
|
|
99
|
+
struct ContentView: View {
|
|
100
|
+
@State private var router = Router()
|
|
101
|
+
|
|
102
|
+
var body: some View {
|
|
103
|
+
TabView {
|
|
104
|
+
${tabContent}
|
|
105
|
+
}
|
|
106
|
+
.environment(router)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#Preview {
|
|
111
|
+
ContentView()
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pascalCase(str: string): string {
|
|
117
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
118
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Templates for generating the networking layer
|
|
2
|
+
|
|
3
|
+
export interface EndpointConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
6
|
+
path: string;
|
|
7
|
+
requestType?: string;
|
|
8
|
+
responseType: string;
|
|
9
|
+
requiresAuth: boolean;
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function generateAPIClient(
|
|
14
|
+
baseURL: string,
|
|
15
|
+
endpoints: EndpointConfig[],
|
|
16
|
+
authStrategy: 'bearer' | 'cookie' | 'none',
|
|
17
|
+
): string {
|
|
18
|
+
const endpointMethods = endpoints.map(generateEndpointMethod).join('\n\n');
|
|
19
|
+
|
|
20
|
+
const authHeader = authStrategy === 'bearer'
|
|
21
|
+
? `
|
|
22
|
+
var authToken: String? {
|
|
23
|
+
get { UserDefaults.standard.string(forKey: "authToken") }
|
|
24
|
+
set { UserDefaults.standard.set(newValue, forKey: "authToken") }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private func authenticatedRequest(_ request: inout URLRequest) {
|
|
28
|
+
if let token = authToken {
|
|
29
|
+
request.setValue("Bearer \\(token)", forHTTPHeaderField: "Authorization")
|
|
30
|
+
}
|
|
31
|
+
}`
|
|
32
|
+
: '';
|
|
33
|
+
|
|
34
|
+
return `// Generated by Morphkit
|
|
35
|
+
import Foundation
|
|
36
|
+
|
|
37
|
+
@Observable
|
|
38
|
+
final class APIClient {
|
|
39
|
+
static let shared = APIClient()
|
|
40
|
+
|
|
41
|
+
private let baseURL = URL(string: "${baseURL}")!
|
|
42
|
+
private let decoder: JSONDecoder = {
|
|
43
|
+
let decoder = JSONDecoder()
|
|
44
|
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
45
|
+
decoder.dateDecodingStrategy = .iso8601
|
|
46
|
+
return decoder
|
|
47
|
+
}()
|
|
48
|
+
private let encoder: JSONEncoder = {
|
|
49
|
+
let encoder = JSONEncoder()
|
|
50
|
+
encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
51
|
+
encoder.dateEncodingStrategy = .iso8601
|
|
52
|
+
return encoder
|
|
53
|
+
}()
|
|
54
|
+
${authHeader}
|
|
55
|
+
|
|
56
|
+
private init() {}
|
|
57
|
+
|
|
58
|
+
${endpointMethods}
|
|
59
|
+
|
|
60
|
+
// MARK: - Private Helpers
|
|
61
|
+
|
|
62
|
+
private func request<T: Decodable>(
|
|
63
|
+
method: String,
|
|
64
|
+
path: String,
|
|
65
|
+
body: (any Encodable)? = nil,
|
|
66
|
+
requiresAuth: Bool = false
|
|
67
|
+
) async throws -> T {
|
|
68
|
+
let url = baseURL.appendingPathComponent(path)
|
|
69
|
+
var request = URLRequest(url: url)
|
|
70
|
+
request.httpMethod = method
|
|
71
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
72
|
+
|
|
73
|
+
if requiresAuth {
|
|
74
|
+
${authStrategy === 'bearer' ? 'authenticatedRequest(&request)' : '// TODO: Add auth'}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if let body {
|
|
78
|
+
request.httpBody = try encoder.encode(body)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
82
|
+
|
|
83
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
84
|
+
throw NetworkError.invalidResponse
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
guard (200...299).contains(httpResponse.statusCode) else {
|
|
88
|
+
throw NetworkError.httpError(statusCode: httpResponse.statusCode, data: data)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return try decoder.decode(T.self, from: data)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
enum NetworkError: LocalizedError {
|
|
96
|
+
case invalidResponse
|
|
97
|
+
case httpError(statusCode: Int, data: Data)
|
|
98
|
+
case decodingError(Error)
|
|
99
|
+
|
|
100
|
+
var errorDescription: String? {
|
|
101
|
+
switch self {
|
|
102
|
+
case .invalidResponse:
|
|
103
|
+
return "Invalid response from server"
|
|
104
|
+
case .httpError(let statusCode, _):
|
|
105
|
+
return "HTTP error: \\(statusCode)"
|
|
106
|
+
case .decodingError(let error):
|
|
107
|
+
return "Failed to decode response: \\(error.localizedDescription)"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function generateEndpointMethod(endpoint: EndpointConfig): string {
|
|
115
|
+
const params: string[] = [];
|
|
116
|
+
const pathWithInterpolation = endpoint.path.replace(/:(\w+)/g, (_, param) => {
|
|
117
|
+
params.push(`${param}: String`);
|
|
118
|
+
return `\\(${param})`;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (endpoint.requestType && ['POST', 'PUT', 'PATCH'].includes(endpoint.method)) {
|
|
122
|
+
params.push(`body: ${endpoint.requestType}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const paramString = params.length > 0 ? params.join(', ') : '';
|
|
126
|
+
const bodyArg = endpoint.requestType && ['POST', 'PUT', 'PATCH'].includes(endpoint.method)
|
|
127
|
+
? ', body: body'
|
|
128
|
+
: '';
|
|
129
|
+
|
|
130
|
+
const returnType = endpoint.responseType === 'Void' ? '' : ` -> ${endpoint.responseType}`;
|
|
131
|
+
const returnStatement = endpoint.responseType === 'Void'
|
|
132
|
+
? `let _: EmptyResponse = try await request(method: "${endpoint.method}", path: "${pathWithInterpolation}"${bodyArg}, requiresAuth: ${endpoint.requiresAuth})`
|
|
133
|
+
: `return try await request(method: "${endpoint.method}", path: "${pathWithInterpolation}"${bodyArg}, requiresAuth: ${endpoint.requiresAuth})`;
|
|
134
|
+
|
|
135
|
+
return ` /// ${endpoint.description}
|
|
136
|
+
func ${endpoint.name}(${paramString}) async throws${returnType} {
|
|
137
|
+
${returnStatement}
|
|
138
|
+
}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function generateEmptyResponseType(): string {
|
|
142
|
+
return `
|
|
143
|
+
struct EmptyResponse: Decodable {}
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Templates for generating SwiftUI views
|
|
2
|
+
|
|
3
|
+
export interface ViewConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
sourceFile: string;
|
|
6
|
+
stateProperties: StateProperty[];
|
|
7
|
+
bodyContent: string;
|
|
8
|
+
helperMethods: string[];
|
|
9
|
+
imports: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StateProperty {
|
|
13
|
+
wrapper: '@State' | '@Binding' | '@Environment' | '@Observable';
|
|
14
|
+
name: string;
|
|
15
|
+
type: string;
|
|
16
|
+
defaultValue?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function generateView(config: ViewConfig): string {
|
|
20
|
+
const imports = ['SwiftUI', ...config.imports]
|
|
21
|
+
.map(i => `import ${i}`)
|
|
22
|
+
.join('\n');
|
|
23
|
+
|
|
24
|
+
const stateProps = config.stateProperties
|
|
25
|
+
.map(p => {
|
|
26
|
+
if (p.wrapper === '@Environment') {
|
|
27
|
+
return ` @Environment(${p.type}.self) private var ${p.name}`;
|
|
28
|
+
}
|
|
29
|
+
const defaultVal = p.defaultValue ? ` = ${p.defaultValue}` : '';
|
|
30
|
+
return ` ${p.wrapper} private var ${p.name}: ${p.type}${defaultVal}`;
|
|
31
|
+
})
|
|
32
|
+
.join('\n');
|
|
33
|
+
|
|
34
|
+
const helpers = config.helperMethods.length > 0
|
|
35
|
+
? '\n' + config.helperMethods.map(m => indentBlock(m, 4)).join('\n\n')
|
|
36
|
+
: '';
|
|
37
|
+
|
|
38
|
+
return `// Generated by Morphkit from: ${config.sourceFile}
|
|
39
|
+
${imports}
|
|
40
|
+
|
|
41
|
+
struct ${config.name}: View {
|
|
42
|
+
${stateProps}
|
|
43
|
+
|
|
44
|
+
var body: some View {
|
|
45
|
+
${indentBlock(config.bodyContent, 8)}
|
|
46
|
+
}${helpers}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#Preview {
|
|
50
|
+
${config.name}()
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function generateListView(
|
|
56
|
+
name: string,
|
|
57
|
+
sourceFile: string,
|
|
58
|
+
itemType: string,
|
|
59
|
+
rowContent: string,
|
|
60
|
+
stateProperties: StateProperty[],
|
|
61
|
+
): string {
|
|
62
|
+
return generateView({
|
|
63
|
+
name,
|
|
64
|
+
sourceFile,
|
|
65
|
+
stateProperties: [
|
|
66
|
+
...stateProperties,
|
|
67
|
+
{ wrapper: '@State', name: 'items', type: `[${itemType}]`, defaultValue: '[]' },
|
|
68
|
+
{ wrapper: '@State', name: 'isLoading', type: 'Bool', defaultValue: 'false' },
|
|
69
|
+
],
|
|
70
|
+
bodyContent: `NavigationStack {
|
|
71
|
+
List(items) { item in
|
|
72
|
+
NavigationLink(value: item) {
|
|
73
|
+
${indentBlock(rowContent, 12)}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
.navigationTitle("${name}")
|
|
77
|
+
.overlay {
|
|
78
|
+
if isLoading {
|
|
79
|
+
ProgressView()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
.task {
|
|
83
|
+
await loadItems()
|
|
84
|
+
}
|
|
85
|
+
}`,
|
|
86
|
+
helperMethods: [
|
|
87
|
+
`private func loadItems() async {
|
|
88
|
+
isLoading = true
|
|
89
|
+
defer { isLoading = false }
|
|
90
|
+
// TODO: Load items from API
|
|
91
|
+
}`,
|
|
92
|
+
],
|
|
93
|
+
imports: [],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function generateFormView(
|
|
98
|
+
name: string,
|
|
99
|
+
sourceFile: string,
|
|
100
|
+
fields: { name: string; type: string; label: string }[],
|
|
101
|
+
): string {
|
|
102
|
+
const stateProperties: StateProperty[] = fields.map(f => ({
|
|
103
|
+
wrapper: '@State' as const,
|
|
104
|
+
name: f.name,
|
|
105
|
+
type: f.type,
|
|
106
|
+
defaultValue: getDefaultValue(f.type),
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const formFields = fields.map(f => {
|
|
110
|
+
switch (f.type) {
|
|
111
|
+
case 'String':
|
|
112
|
+
return `TextField("${f.label}", text: $${f.name})`;
|
|
113
|
+
case 'Bool':
|
|
114
|
+
return `Toggle("${f.label}", isOn: $${f.name})`;
|
|
115
|
+
case 'Date':
|
|
116
|
+
return `DatePicker("${f.label}", selection: $${f.name})`;
|
|
117
|
+
case 'Int':
|
|
118
|
+
case 'Double':
|
|
119
|
+
return `TextField("${f.label}", value: $${f.name}, format: .number)`;
|
|
120
|
+
default:
|
|
121
|
+
return `TextField("${f.label}", text: $${f.name})`;
|
|
122
|
+
}
|
|
123
|
+
}).join('\n');
|
|
124
|
+
|
|
125
|
+
return generateView({
|
|
126
|
+
name,
|
|
127
|
+
sourceFile,
|
|
128
|
+
stateProperties: [
|
|
129
|
+
...stateProperties,
|
|
130
|
+
{ wrapper: '@Environment', name: 'dismiss', type: '\\.dismiss' },
|
|
131
|
+
],
|
|
132
|
+
bodyContent: `NavigationStack {
|
|
133
|
+
Form {
|
|
134
|
+
Section {
|
|
135
|
+
${indentBlock(formFields, 12)}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
Section {
|
|
139
|
+
Button("Save") {
|
|
140
|
+
save()
|
|
141
|
+
}
|
|
142
|
+
.frame(maxWidth: .infinity)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
.navigationTitle("${name}")
|
|
146
|
+
.toolbar {
|
|
147
|
+
ToolbarItem(placement: .cancellationAction) {
|
|
148
|
+
Button("Cancel") { dismiss() }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}`,
|
|
152
|
+
helperMethods: [
|
|
153
|
+
`private func save() {
|
|
154
|
+
// TODO: Implement save
|
|
155
|
+
dismiss()
|
|
156
|
+
}`,
|
|
157
|
+
],
|
|
158
|
+
imports: [],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function generateDetailView(
|
|
163
|
+
name: string,
|
|
164
|
+
sourceFile: string,
|
|
165
|
+
modelType: string,
|
|
166
|
+
sections: { title: string; content: string }[],
|
|
167
|
+
): string {
|
|
168
|
+
const sectionContent = sections
|
|
169
|
+
.map(s => `Section("${s.title}") {\n${indentBlock(s.content, 4)}\n}`)
|
|
170
|
+
.join('\n\n');
|
|
171
|
+
|
|
172
|
+
return generateView({
|
|
173
|
+
name,
|
|
174
|
+
sourceFile,
|
|
175
|
+
stateProperties: [],
|
|
176
|
+
bodyContent: `ScrollView {
|
|
177
|
+
VStack(alignment: .leading, spacing: 16) {
|
|
178
|
+
${indentBlock(sectionContent, 8)}
|
|
179
|
+
}
|
|
180
|
+
.padding()
|
|
181
|
+
}`,
|
|
182
|
+
helperMethods: [],
|
|
183
|
+
imports: [],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function generateTabView(
|
|
188
|
+
tabs: { name: string; icon: string; view: string }[],
|
|
189
|
+
): string {
|
|
190
|
+
const tabItems = tabs
|
|
191
|
+
.map(t => ` Tab("${t.name}", systemImage: "${t.icon}") {
|
|
192
|
+
${t.view}()
|
|
193
|
+
}`)
|
|
194
|
+
.join('\n');
|
|
195
|
+
|
|
196
|
+
return `// Generated by Morphkit
|
|
197
|
+
import SwiftUI
|
|
198
|
+
|
|
199
|
+
struct ContentView: View {
|
|
200
|
+
var body: some View {
|
|
201
|
+
TabView {
|
|
202
|
+
${tabItems}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#Preview {
|
|
208
|
+
ContentView()
|
|
209
|
+
}
|
|
210
|
+
`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getDefaultValue(type: string): string {
|
|
214
|
+
switch (type) {
|
|
215
|
+
case 'String': return '""';
|
|
216
|
+
case 'Bool': return 'false';
|
|
217
|
+
case 'Int': return '0';
|
|
218
|
+
case 'Double': return '0.0';
|
|
219
|
+
case 'Date': return '.now';
|
|
220
|
+
default: return '""';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function indentBlock(content: string, spaces: number): string {
|
|
225
|
+
const indent = ' '.repeat(spaces);
|
|
226
|
+
return content
|
|
227
|
+
.split('\n')
|
|
228
|
+
.map(line => line.length > 0 ? `${indent}${line}` : line)
|
|
229
|
+
.join('\n');
|
|
230
|
+
}
|