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 fullName and email once. AppleButton persists them to SettingsManager.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 in SignInView to match your brand. The layout is a plain VStack, easy to restyle.
  • Reorder the providers. To lead with email instead of Apple, move the AppleButton below and promote the options link β€” or inline the SignInOptions form directly. The auth wiring doesn't change.
  • Add another provider. Add a method to AuthManager, a button to SignInView, and let the new path set currentSession like the others. The LandingView gate 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.

  • 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.
Previous
OnboardingView