import SwiftUI import ProxyCore struct ComposeEditorView: View { let requestId: Int64 @State private var method = "GET" @State private var url = "" @State private var headers: [(key: String, value: String)] = [] @State private var queryParams: [(key: String, value: String)] = [] @State private var bodyText = "" @State private var selectedTab: EditorTab = .headers @State private var isSending = false @State private var responseStatus: Int? @State private var responseBody: String? @State private var showHeaderEditor = false @State private var showQueryEditor = false @State private var didLoad = false private let composeRepo = ComposeRepository() enum EditorTab: String, CaseIterable { case headers = "Headers" case query = "Query" case body = "Body" } var body: some View { VStack(spacing: 0) { // Method + URL HStack(spacing: 12) { Menu { ForEach(ProxyConstants.httpMethods, id: \.self) { m in Button(m) { method = m } } } label: { HStack(spacing: 4) { Text(method) .font(.subheadline.weight(.semibold)) .foregroundStyle(.green) Image(systemName: "chevron.down") .font(.caption2) .foregroundStyle(.secondary) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8)) } TextField("Tap to edit URL", text: $url) .textFieldStyle(.roundedBorder) .font(.subheadline) .textInputAutocapitalization(.never) .autocorrectionDisabled() } .padding() // Tabs Picker("Tab", selection: $selectedTab) { ForEach(EditorTab.allCases, id: \.self) { tab in Text(tab.rawValue).tag(tab) } } .pickerStyle(.segmented) .padding(.horizontal) // Tab Content ScrollView { VStack(alignment: .leading, spacing: 12) { switch selectedTab { case .headers: headerEditor case .query: queryEditor case .body: bodyEditor } } .padding() } // Response if responseStatus != nil || responseBody != nil { Divider() VStack(alignment: .leading, spacing: 8) { if let status = responseStatus { HStack { Text("Response") .font(.caption.weight(.semibold)) StatusBadge(statusCode: status) } .padding(.horizontal) .padding(.top, 8) } if let body = responseBody { ScrollView { Text(body) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) } .frame(maxHeight: 200) NavigationLink { FullResponseView(statusCode: responseStatus, responseBody: body) } label: { Label("View Full Response", systemImage: "arrow.up.left.and.arrow.down.right") .font(.caption) } .padding(.horizontal) .padding(.bottom, 8) } } .background(Color(.systemGray6)) } } .navigationTitle("Edit Request") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { Task { await sendRequest() } } label: { if isSending { ProgressView() } else { Text("Send") .font(.headline) } } .buttonStyle(.borderedProminent) .disabled(url.isEmpty || isSending) } } .sheet(isPresented: $showHeaderEditor) { HeaderEditorSheet(headers: headers) { updated in headers = updated } } .sheet(isPresented: $showQueryEditor) { QueryEditorSheet(parameters: queryParams) { updated in queryParams = updated } } .task { guard !didLoad else { return } didLoad = true loadFromDB() } } // MARK: - Tab Views private var headerEditor: some View { VStack(alignment: .leading, spacing: 8) { if headers.isEmpty { EmptyStateView( icon: "list.bullet.rectangle", title: "No Headers", subtitle: "Tap 'Edit Headers' to add headers." ) } else { ForEach(headers.indices, id: \.self) { index in HStack { Text(headers[index].key) .font(.subheadline.weight(.medium)) Spacer() Text(headers[index].value) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) } .padding(.vertical, 2) } } Button("Edit Headers") { showHeaderEditor = true } .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) } } private var queryEditor: some View { VStack(alignment: .leading, spacing: 8) { if queryParams.isEmpty { EmptyStateView( icon: "questionmark.circle", title: "No Query Parameters", subtitle: "Tap 'Edit Parameters' to add query parameters." ) } else { ForEach(queryParams.indices, id: \.self) { index in HStack { Text(queryParams[index].key) .font(.subheadline.weight(.medium)) Spacer() Text(queryParams[index].value) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) } .padding(.vertical, 2) } } Button("Edit Parameters") { showQueryEditor = true } .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) } } private var bodyEditor: some View { VStack(alignment: .leading, spacing: 8) { TextEditor(text: $bodyText) .font(.system(.caption, design: .monospaced)) .frame(minHeight: 200) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color(.systemGray4)) ) } } // MARK: - DB Load private func loadFromDB() { do { if let request = try composeRepo.fetch(id: requestId) { method = request.method url = request.url ?? "" bodyText = request.body ?? "" headers = decodeKeyValues(request.headers) queryParams = decodeKeyValues(request.queryParameters) responseStatus = request.responseStatus if let data = request.responseBody, let str = String(data: data, encoding: .utf8) { responseBody = str } } } catch { print("Failed to load compose request: \(error)") } } // MARK: - Send private func sendRequest() async { // Build URL with query parameters guard var components = URLComponents(string: url) else { return } if !queryParams.isEmpty { var items = components.queryItems ?? [] for param in queryParams { items.append(URLQueryItem(name: param.key, value: param.value)) } components.queryItems = items } guard let requestURL = components.url else { return } isSending = true defer { isSending = false } var request = URLRequest(url: requestURL) request.httpMethod = method for header in headers { request.setValue(header.value, forHTTPHeaderField: header.key) } if !bodyText.isEmpty { request.httpBody = bodyText.data(using: .utf8) } do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { responseStatus = httpResponse.statusCode } if let string = String(data: data, encoding: .utf8) { responseBody = string } else { responseBody = "\(data.count) bytes (binary)" } } catch { responseBody = "Error: \(error.localizedDescription)" responseStatus = nil } // Save to DB saveToDB() } private func saveToDB() { do { if var request = try composeRepo.fetch(id: requestId) { request.method = method request.url = url request.headers = encodeKeyValues(headers) request.queryParameters = encodeKeyValues(queryParams) request.body = bodyText.isEmpty ? nil : bodyText request.responseStatus = responseStatus request.responseBody = responseBody?.data(using: .utf8) request.lastSentAt = Date().timeIntervalSince1970 try composeRepo.update(request) } } catch { print("Failed to save compose request: \(error)") } } // MARK: - Helpers private func encodeKeyValues(_ pairs: [(key: String, value: String)]) -> String? { guard !pairs.isEmpty else { return nil } var dict: [String: String] = [:] for pair in pairs { dict[pair.key] = pair.value } guard let data = try? JSONEncoder().encode(dict) else { return nil } return String(data: data, encoding: .utf8) } private func decodeKeyValues(_ json: String?) -> [(key: String, value: String)] { guard let json, let data = json.data(using: .utf8), let dict = try? JSONDecoder().decode([String: String].self, from: data) else { return [] } return dict.map { (key: $0.key, value: $0.value) }.sorted { $0.key < $1.key } } }