PadXcode-iPad/Onboarding/OnboardingView.swift
2026-04-12 22:07:35 -07:00

215 lines
11 KiB
Swift

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..<steps.count, id: \.self) { i in
Circle().fill(i <= step ? Color.accentColor : Color.secondary.opacity(0.3))
.frame(width: 8, height: 8)
.animation(.spring, value: step)
}
}.padding(.top, 20)
// Content
TabView(selection: $step) {
welcomeStep.tag(0)
daemonStep.tag(1)
workingCopyStep.tag(2)
readyStep.tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))
// Navigation
HStack {
if step > 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
}
}