2026-04-12 00:46:30 -07:00
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 ) {
// P r o g r e s s d o t s
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 )
// C o n t e n t
TabView ( selection : $ step ) {
welcomeStep . tag ( 0 )
daemonStep . tag ( 1 )
workingCopyStep . tag ( 2 )
readyStep . tag ( 3 )
}
. tabViewStyle ( . page ( indexDisplayMode : . never ) )
// N a v i g a t i o n
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: - S t e p s
private var welcomeStep : some View {
VStack ( spacing : 28 ) {
2026-04-12 22:07:35 -07:00
Image ( systemName : " laptopcomputer.and.ipad " ) . font ( . system ( size : 72 , weight : . thin ) ) . foregroundStyle ( Color . accentColor ) . padding ( . top , 40 )
2026-04-12 00:46:30 -07:00
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: - A c t i o n s
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
}
}