Models and ViewModels

SignInViewModel

SignInViewModel is the thin, testable layer between your sign-in UI and the auth backend. It does two jobs and nothing more: validate the form before you hit the network, and forward the call to AuthManager once the input is clean. That separation keeps the views dumb (they just render fields and bind to one error string) and keeps the validation rules in one place you can unit-test without ever touching Supabase.

It lives at Views/SignIns/SignInViewModel.swift.

The view model

It's a modern @Observable class — not the legacy ObservableObject — and it's @MainActor-isolated end to end, so the errorText it publishes to the UI is always mutated on the main thread.

@MainActor
@Observable
final class SignInViewModel {

    var errorText: String?

    func isFormValid(email: String, password: String) -> Bool {
        errorText = nil
        guard email.isValidEmail() else {
            errorText = "Provide correct email address"
            return false
        }

        guard password.count >= 7 else {
            errorText = "Provide password that is at least 7 characters long"
            return false
        }
        return true
    }

    @discardableResult
    func registerNewUserWithEmail(email: String, password: String) async throws -> AppUser {
        if isFormValid(email: email, password: password) {
            return try await AuthManager.shared.registerNewUserWithEmail(email: email, password: password)
        } else {
            Logger.viewCycle.error("form is invalid")
            throw NSError()
        }
    }

    func sendMagicLink(email: String) async throws {
        if email.isValidEmail() {
            try await AuthManager.shared.sendMagicLink(email: email)
        } else {
            errorText = "Provide correct email address"
        }
    }

    @discardableResult
    func signInWithEmail(email: String, password: String) async throws -> AppUser {
        if isFormValid(email: email, password: password) {
            return try await AuthManager.shared.signInWithEmail(email: email, password: password)
        } else {
            Logger.viewCycle.error("form is invalid")
            throw NSError()
        }
    }
}

@Observable, not @Published

There is exactly one stored property — errorText: String? — and it's a plain var. With the @Observable macro you do not annotate properties with @Published; the macro tracks them automatically, and SwiftUI re-renders any view that reads errorText. The view model holds no isAuthenticated/session flag — session state is owned by AuthManager, not here.

The one property

  • errorText: String? — the single channel the view model uses to talk back to the UI. It's nil when there's nothing to show, and a human-readable message when validation fails. isFormValid clears it at the top of every run so a fixed field doesn't keep showing a stale error.

That's the whole surface. There's no loading flag and no result property — the async methods return the AppUser (or throw), so the calling view decides what "success" and "failure" look like.

How validation works

isFormValid(email:password:) is the gatekeeper, and it runs before any network call. The rules are intentionally simple and live entirely client-side:

  1. Reset. errorText = nil — start from a clean slate.

  2. Email. email.isValidEmail() must pass, or errorText becomes "Provide correct email address". The check is a regex predicate defined in Utils/Extensions/String+Extensions.swift:

    func isValidEmail() -> Bool {
        let emailFormat = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailFormat)
        return emailPredicate.evaluate(with: self)
    }
    
  3. Password. Must be at least 7 characters, or errorText becomes "Provide password that is at least 7 characters long".

The magic-link path is lighter — it only needs a valid email (no password), so sendMagicLink calls isValidEmail() directly instead of isFormValid.

How it drives the view

The views never own the view model with @State — it's injected once and shared via the environment, so the sign-in screen and the registration sheet read the same errorText:

struct SignInOptions: View {
    @State private var email: String = ""
    @State private var password: String = ""
    @State private var isMagicLinkSelected: Bool = true
    @Environment(SignInViewModel.self) private var signInViewModel
    @Environment(AuthManager.self) private var authManager
    // …
}

Rendering the error

The error is a plain read of errorText — no binding, no callback. When the view model sets it, the view re-renders and the message appears:

if signInViewModel.errorText != nil {
    Text(signInViewModel.errorText!)
        .foregroundStyle(.red)
        .font(.footnote)
}

Calling the async methods

Because the methods are async throws, the views call them from inside a Task. SignInOptions picks magic-link vs. password based on a toggle:

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")
}

RegistrationView does the same for sign-up, dismissing the sheet on success and logging on failure:

Button {
    Task {
        do {
            try await signInViewModel.registerNewUserWithEmail(email: email, password: password)
            dismiss()
        } catch {
            Logger.viewCycle.error("issue with sign in")
        }
    }
} label: {
    Text("Register")
}

Validation failure throws — handle it

When the form is invalid, signInWithEmail and registerNewUserWithEmail log and throw (the user-facing reason is already in errorText). Always call them inside do/catch (or use try?) so an invalid-form throw doesn't crash the TaskRegistrationView shows the pattern. sendMagicLink is the exception: on a bad email it sets errorText and returns without throwing.

Where the call actually goes

The view model is a router, not the auth engine. Every successful path hands off to the AuthManager singleton, which owns the Supabase client and the live session:

  • signInWithEmailAuthManager.shared.signInWithEmail(...)
  • registerNewUserWithEmailAuthManager.shared.registerNewUserWithEmail(...)
  • sendMagicLinkAuthManager.shared.sendMagicLink(...)

See the Authentication Flow page for what happens on the other side — session creation, the Sign in with Apple path, and how the magic-link deep link is caught and exchanged for a session.

Customize / extend it

  • Tune the rules. Change the password floor (the password.count >= 7 guard) or the email regex in String.isValidEmail(). Because both views go through isFormValid, one edit updates every screen.
  • Localize the messages. The two strings in isFormValid (and the one in sendMagicLink) are the only user-facing copy here — wrap them in String(localized:) to ship in multiple languages.
  • Return richer errors. The invalid-form path throws a bare NSError(). If a caller needs to distinguish "bad form" from "network failure", define a typed enum SignInError: Error and throw cases instead — the views already catch via do/catch, so nothing downstream breaks.
  • Add a provider. To support another sign-in method, add the method on AuthManager, then add a matching forwarder here that runs the right validation first. Keep the pattern: validate, then delegate.

Testing

The view model is built to be tested in isolation. isFormValid is pure and synchronous — assert that bad emails and short passwords return false and set the expected errorText, and that a clean pair returns true and leaves errorText nil. For the async methods, the only coupling is to AuthManager.shared; exercise the validation gate (which short-circuits before any network call) and confirm an invalid form throws without ever reaching the backend.

  • Authentication Flow — the AuthManager backend this view model forwards to, plus magic-link deep-linking and Sign in with Apple.
  • Feature Model — the onboarding rows users see right before they reach these sign-in screens.
Previous
Feature