import SwiftUI import UIKit struct OnboardingView: View { @EnvironmentObject var daemonConfig: DaemonConfiguration @AppStorage("hasCompletedOnboarding") private var hasCompleted = false @State private var step = 0 @State private var tailscaleIP = "" @State private var wcApiKey = "" @State private var testResult: TestResult = .idle enum TestResult: Equatable { case idle, testing, success, failure(String) static func ==(a: TestResult, b: TestResult) -> Bool { switch (a,b) { case (.idle,.idle),(.testing,.testing),(.success,.success): return true case (.failure(let x),.failure(let y)): return x == y default: return false } } } private let steps = ["Welcome","Connect to Mac","Working Copy","Ready"] var body: some View { NavigationStack { VStack(spacing: 0) { // Progress dots HStack(spacing: 6) { ForEach(0.. 0 { Button("Back") { withAnimation { step -= 1 } }.buttonStyle(.bordered) } Spacer() if step < steps.count - 1 { Button("Next") { withAnimation { step += 1 } } .buttonStyle(.borderedProminent) .disabled(step == 1 && testResult != .success) } else { Button("Get Started") { applyAndFinish() } .buttonStyle(.borderedProminent) .disabled(testResult != .success && tailscaleIP.isEmpty) } } .padding(.horizontal, 24).padding(.bottom, 32) } } } // MARK: - Steps private var welcomeStep: some View { VStack(spacing: 28) { Image(systemName: "laptopcomputer.and.ipad").font(.system(size:72,weight:.thin)).foregroundStyle(Color.accentColor).padding(.top,40) VStack(spacing:10) { Text("Welcome to PadXcode").font(.largeTitle.bold()).multilineTextAlignment(.center) Text("A native Swift IDE on your iPad, backed by your Mac.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24) } LazyVGrid(columns: [.init(.flexible()),.init(.flexible())], spacing:12) { ForEach([("swift","Runestone Editor",.orange),("sparkles","Tree-sitter Syntax",.yellow), ("hammer.fill","xcodebuild Builds",.blue),("iphone.radiowaves.left.and.right","devicectl Deploy",.green), ("wand.and.stars","sourcekit-lsp",.purple),("arrow.triangle.branch","Working Copy Git",.pink)] as [(String,String,Color)], id:\.1) { icon,name,color in HStack(spacing:10) { Image(systemName:icon).font(.title3).foregroundStyle(color).frame(width:32) Text(name).font(.subheadline); Spacer() }.padding(12).background(.quaternary, in: RoundedRectangle(cornerRadius:10)) } }.padding(.horizontal,24) Spacer(minLength:40) } } private var daemonStep: some View { VStack(spacing: 28) { Image(systemName:"network").font(.system(size:72,weight:.thin)).foregroundStyle(.blue).padding(.top,40) VStack(spacing:10) { Text("Connect to Your Mac").font(.largeTitle.bold()).multilineTextAlignment(.center) Text("Enter your Mac's Tailscale IP to connect to the daemon.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24) } VStack(spacing:14) { VStack(alignment:.leading,spacing:6) { Label("Tailscale IP Address", systemImage:"network").font(.subheadline.bold()) TextField("100.x.x.x", text:$tailscaleIP) .textFieldStyle(.roundedBorder).keyboardType(.numbersAndPunctuation) .autocorrectionDisabled().autocapitalization(.none) .onChange(of:tailscaleIP) { _,_ in testResult = .idle } } GroupBox { VStack(alignment:.leading,spacing:6) { Label("Mac terminal:", systemImage:"terminal").font(.caption.bold()) Text("open mac-daemon/PadXcodeDaemon.xcodeproj") .font(.system(.caption,design:.monospaced)).padding(6) .frame(maxWidth:.infinity,alignment:.leading) .background(Color(.secondarySystemBackground),in:RoundedRectangle(cornerRadius:6)) } } Button { Task { await testConnection() } } label: { HStack { if case .testing = testResult { ProgressView().scaleEffect(0.8) } Text(testLabel) }.frame(maxWidth:.infinity) } .buttonStyle(.borderedProminent) .disabled(tailscaleIP.isEmpty || testResult == .testing) if case .failure(let m) = testResult { Label(m, systemImage:"xmark.circle").font(.caption).foregroundStyle(.red) } if case .success = testResult { Label("Connected to daemon ✓", systemImage:"checkmark.circle.fill").font(.caption).foregroundStyle(.green) } } .padding(20).background(.regularMaterial, in:RoundedRectangle(cornerRadius:14)).padding(.horizontal,24) Spacer(minLength:40) } } private var workingCopyStep: some View { VStack(spacing: 28) { Image(systemName:"arrow.triangle.branch").font(.system(size:72,weight:.thin)).foregroundStyle(.purple).padding(.top,40) VStack(spacing:10) { Text("Working Copy (Optional)").font(.largeTitle.bold()).multilineTextAlignment(.center) Text("Connect Working Copy to commit and push from within PadXcode.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24) } VStack(spacing:14) { let installed = UIApplication.shared.canOpenURL(URL(string:"working-copy://")!) if installed { Label("Working Copy detected ✓", systemImage:"checkmark.circle.fill").foregroundStyle(.green).font(.subheadline) } else { Label("Working Copy not installed", systemImage:"exclamationmark.triangle").foregroundStyle(.orange).font(.subheadline) } VStack(alignment:.leading,spacing:6) { Label("API Key", systemImage:"key.fill").font(.subheadline.bold()) SecureField("Paste from Working Copy → Settings → Advanced", text:$wcApiKey) .textFieldStyle(.roundedBorder).autocorrectionDisabled().autocapitalization(.none) } Text("For local git over Tailscale: clone via SSH in Working Copy using ssh://user@100.x.x.x/path/to/repo.git") .font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) } .padding(20).background(.regularMaterial, in:RoundedRectangle(cornerRadius:14)).padding(.horizontal,24) Spacer(minLength:40) } } private var readyStep: some View { VStack(spacing: 28) { Image(systemName:"checkmark.seal.fill").font(.system(size:72,weight:.thin)).foregroundStyle(.green).padding(.top,40) VStack(spacing:10) { Text("You're All Set").font(.largeTitle.bold()).multilineTextAlignment(.center) Text("Open a project and start building.").font(.body).foregroundStyle(.secondary) } VStack(spacing:10) { summaryRow("network", .blue, "Mac Daemon", tailscaleIP.isEmpty ? "Not configured" : tailscaleIP) summaryRow("arrow.triangle.branch", .purple, "Working Copy", wcApiKey.isEmpty ? "Not configured" : "Connected") summaryRow("wand.and.stars", .yellow, "sourcekit-lsp", "Auto-starts with project") summaryRow("hammer.fill", .orange, "Build & Deploy", "Ready") } .padding(20).background(.regularMaterial, in:RoundedRectangle(cornerRadius:14)).padding(.horizontal,24) Spacer(minLength:40) } } private func summaryRow(_ icon: String, _ color: Color, _ label: String, _ value: String) -> some View { HStack { Image(systemName:icon).foregroundStyle(color).frame(width:24) Text(label).font(.subheadline) Spacer() Text(value).font(.subheadline).foregroundStyle(.secondary) } } // MARK: - Actions private var testLabel: String { switch testResult { case .testing: return "Testing…"; case .success: return "Connected ✓"; default: return "Test Connection" } } private func testConnection() async { testResult = .testing let host = tailscaleIP.trimmingCharacters(in:.whitespaces) guard let url = URL(string:"http://\(host):8080/health") else { testResult = .failure("Invalid IP"); return } var req = URLRequest(url:url); req.timeoutInterval = 4 do { let (_,resp) = try await URLSession.shared.data(for:req) testResult = (resp as? HTTPURLResponse)?.statusCode == 200 ? .success : .failure("Unexpected response") } catch { testResult = .failure(error.localizedDescription) } } private func applyAndFinish() { daemonConfig.host = tailscaleIP.trimmingCharacters(in:.whitespaces) daemonConfig.port = 8080 if !wcApiKey.isEmpty { UserDefaults.standard.set(wcApiKey, forKey:"workingCopyAPIKey") } hasCompleted = true } }