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'snilwhen there's nothing to show, and a human-readable message when validation fails.isFormValidclears 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:
Reset.
errorText = nil— start from a clean slate.Email.
email.isValidEmail()must pass, orerrorTextbecomes"Provide correct email address". The check is a regex predicate defined inUtils/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) }Password. Must be at least 7 characters, or
errorTextbecomes"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 Task — RegistrationView 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:
signInWithEmail→AuthManager.shared.signInWithEmail(...)registerNewUserWithEmail→AuthManager.shared.registerNewUserWithEmail(...)sendMagicLink→AuthManager.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 >= 7guard) or the email regex inString.isValidEmail(). Because both views go throughisFormValid, one edit updates every screen. - Localize the messages. The two strings in
isFormValid(and the one insendMagicLink) are the only user-facing copy here — wrap them inString(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 typedenum SignInError: Errorand throw cases instead — the views already catch viado/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.
Related
- Authentication Flow — the
AuthManagerbackend 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.