Views and Components
Sign In
SignInView is the door into the app. When LandingView sees no active session, this is what it shows β and it's built Apple-first: a logo, a tagline, a prominent Sign in with Apple button, and a quiet link to "other sign in options" (email/password and magic link). That hierarchy is intentional. Sign in with Apple is the lowest-friction, highest-trust option for an indie iOS app, so it's front and center; everything else is one tap away.
The file is ShipThatApp/Views/SignIns/SignIn.swift (the struct is SignInView).
The file is SignIn.swift, the type is SignInView
Don't go looking for a SignInView.swift β the type lives in SignIn.swift. The secondary screen (email + magic link) is a separate file, SignInOptions.swift, reached by navigation rather than embedded here.
The view itself
SignInView is presentation-only. It holds no form state β it pulls the router from the environment and delegates the actual auth work to AppleButton and AuthManager:
struct SignInView: View {
@Environment(AppRouter.self) private var router
var body: some View {
VStack(spacing: 20) {
Spacer()
Image("Logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
Text("Ship That App")
.font(.largeTitle).fontWeight(.bold)
.padding(.top, 20)
Text("Blast Off with a Bang π: Less Setup, More Skyrocketing!")
.font(.footnote).fontWeight(.medium)
.multilineTextAlignment(.center)
// The primary call to action.
AppleButton()
.frame(height: 50)
.frame(maxWidth: .infinity)
.padding()
// The quiet secondary path.
Button {
router.navigateTo(.signInOptions)
} label: {
Text("View other sign in options")
.foregroundStyle(.accent)
}
Spacer()
}
.navigationTitle("Sign In")
}
}
The whole screen is two real actions: tap AppleButton, or router.navigateTo(.signInOptions) to reveal email/magic-link. Navigation goes through AppRouter, so .signInOptions is a typed destination, not an inline NavigationLink.
Sign in with Apple: the secure default
The Apple button is its own view, AppleButton.swift, and it does the security-sensitive work properly. ShipThatApp uses a nonce to bind the Apple authorization to this specific request β the SHA-256 hash goes to Apple, and the raw nonce is later sent to Supabase to prove the returned identity token was minted for this request (replay protection):
struct AppleButton: View {
@Environment(AuthManager.self) private var authManager
@Environment(SettingsManager.self) private var settingsManager
@State private var currentNonce: String?
var body: some View {
SignInWithAppleButton { request in
let nonce = CryptoUtils.shared.randomNonceString()
currentNonce = nonce
request.requestedScopes = [.email, .fullName]
request.nonce = CryptoUtils.shared.sha256(nonce) // hashed nonce to Apple
} onCompletion: { result in
switch result {
case .success(let authResults):
Task {
guard
let credential = authResults.credential as? ASAuthorizationAppleIDCredential,
let nonce = currentNonce,
let idToken = credential.identityToken
.flatMap({ String(data: $0, encoding: .utf8) })
else { return }
// Apple only sends name/email on FIRST sign-up β capture it now.
settingsManager.updateUserProfile(
firstName: credential.fullName?.givenName,
lastName: credential.fullName?.familyName,
email: credential.email
)
try await authManager.signInWithApple(idToken: idToken, nonce: nonce)
}
case .failure(let error):
Logger.viewCycle.error("Authorisation failed: \(error.localizedDescription)")
}
}
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
}
}
Two details that save you from classic Apple Sign In bugs:
- Name and email are captured on first sign-up only. Apple returns
fullNameandemailonce.AppleButtonpersists them toSettingsManager.updateUserProfile(...)immediately, so the rest of the app (the Welcome greeting, the Settings profile header) has a name even on later launches when Apple sends nothing. - The button adapts to color scheme. Black on light, white on dark β matching Apple's Human Interface Guidelines.
The secondary screen: SignInOptions
Tapping "View other sign in options" routes to SignInOptions (file SignInOptions.swift), which offers magic link (default) and email + password, plus a sheet to register a new account. It's a small form wired to SignInViewModel:
Button {
Task {
userEmail = email
if isMagicLinkSelected {
try await signInViewModel.sendMagicLink(email: email)
} else {
try await signInViewModel.signInWithEmail(email: email, password: password)
}
}
} label: {
Text(isMagicLinkSelected ? "Email me a signup link" : "Sign in with password")
}
A toggle flips between the two modes (the password field appears only for email/password), validation errors from signInViewModel.errorText and authManager.errorMessage render inline in red, and a "Register as new user" button presents RegistrationView as a sheet. The magic-link callback is handled back in LandingView via .onOpenURL β see Authentication for that round trip.
How sign-in flows back into the app
There is no manual navigation after a successful sign-in. All three paths converge on AuthManager, which sets currentSession. Because LandingView renders its tab shell only when authManager.currentSession != nil, that property change is the navigation β SwiftUI swaps the sign-in stack for the authed TabView automatically.
SignInView βββΊ AppleButton βββββΊ AuthManager.signInWithApple(idToken:nonce:)
ββΊ SignInOptions βββΊ AuthManager.signInWithEmail / sendMagicLink
β
βΌ
authManager.currentSession != nil
β
βΌ
LandingView re-renders into the authed TabView
Customize / extend it
- Reskin the entry screen. Swap the
"Logo"asset, the title, and the tagline inSignInViewto match your brand. The layout is a plainVStack, easy to restyle. - Reorder the providers. To lead with email instead of Apple, move the
AppleButtonbelow and promote the options link β or inline theSignInOptionsform directly. The auth wiring doesn't change. - Add another provider. Add a method to
AuthManager, a button toSignInView, and let the new path setcurrentSessionlike the others. TheLandingViewgate handles the rest. - Tighten validation. Email/password rules live in
SignInViewModel.isFormValid(...)(valid email + 7-char minimum). Adjust there, not in the views.
Views stay dumb; managers stay smart
SignInView has zero business logic and AppleButton only marshals the Apple credential β the real auth lives in AuthManager and validation in SignInViewModel. Keep new auth logic there so every screen benefits and the views stay trivial to restyle.
Related
- Authentication β the end-to-end flow, including the magic-link deep-link callback.
- AuthManager β the Supabase-backed session source of truth all three paths converge on.
- SignInViewModel β form validation and the thin bridge to
AuthManager. - LandingView β the auth gate that shows this screen and swaps to the app on success.
- Settings β where the captured Apple profile (name/email) is displayed and editable.