Skip to content

feat(sample): add full-customization example covering every auth screen - #1420

Draft
demolaf wants to merge 23 commits into
auth/phone-content-slotsfrom
sample/full-customization
Draft

demolaf wants to merge 23 commits into
auth/phone-content-slotsfrom
sample/full-customization

Conversation

@demolaf

@demolaf demolaf commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Related to #1400.

Adds a full customization example to the SwiftUI sample: every auth screen drawn by the app, with three live-switchable themes from the "Firebase auth flows" Figma file.

Draft: based on #1412 with #1414 to #1417 merged in, so the diff includes them. It gets rebased onto main once #1402 to #1417 merge.

Changes

  • .pickerContent { state in } draws the Figma Landing; .pickerDestination and .signedInContent replace every other screen through its content slot.
  • Doggo, Grid and Coffee Spark come from the iOS Landing frames. Screens Figma lacks follow the Android demo's copy.
  • The theming modifiers restyle the stock screens left in the flow: update password, reauthentication and delete confirmation.
  • The example owns its AuthService to enable email-link sign-in without changing other examples. Fonts ship with their OFL licenses.

Preview


Maintainer note: Fixes internal CPRN-523

Add EmailAuthScreen, PasswordRecoveryScreen and EmailLinkScreen, which own the email flows' state and logic and hand their content a state struct. The stock markup moves to DefaultEmailAuthContent, DefaultPasswordRecoveryContent and DefaultEmailLinkContent, and the existing views render them through the screens.
Add goToEmailLink and hideSuccess to the state structs, keep the recovery screen when its success sheet is swiped away, handle an opened email link once even when custom content has several root views, move focus to confirm password on Go during sign-up, and align the recovery screen's isLoading with the other screens.
Add public MFAEnrolmentScreen, which owns the enrolment state, reauthentication and error reporting and passes its content an MFAEnrollmentContentState. DefaultMFAEnrolmentContent holds the stock markup, and MFAEnrolmentView now renders it through the screen.
Map finished enrolment sessions back to factor selection, require the selected factor to be allowed before starting, keep copy feedback from being cut short, clear focus between steps, and cover the step and isValid rules with unit tests.
Map sessions past expiresAt back to factor selection, only send codes from the SMS steps, disable Resend Code while loading, and note that custom content sets its own navigation title.
Move the startEnrollment, sendCode and enroll guards into Step helpers with unit tests, and replace the unused focus enum with a single phone number focus flag.
Add public MFAResolutionScreen and MFAManagementScreen, which own the screen state and actions and pass MFAResolutionContentState / MFAManagementContentState to consumer content. DefaultMFAResolutionContent and DefaultMFAManagementContent hold the stock markup, and MFAResolutionView and MFAManagementView keep their public signatures by rendering them through the screens. MFAManagementScreen keeps the reauthentication modifier outside the content, so the reauth sheet presents for custom content too.
Wrap each MFA screen's content in one container so the reauthentication sheets and lifecycle modifiers attach once, even when the content returns several views or none. Setting selectedHintIndex resets the challenge again on every tap, a code sent for a hint the user switched away from is dropped, cancelling reauthentication no longer sets errorMessage, and refresh clears it.
Add signedInContent(_:) to AuthPickerView and AuthPickerContent, so apps can replace the screen the authentication sheet shows to signed-in users. The content receives a SignedInContentState with the sign-out, delete-account, verify-email and navigation actions; an internal SignedInScreen keeps running them, including the delete confirmation, reauthentication and alerts. DefaultSignedInContent holds the stock markup and is the slot's default, and SignedInView renders it unchanged and gains a public init.

The slot carries through pickerContent(_:) and pickerDestination(_:) in either order, and the progress overlay stays outside it. Swiping away a reauthentication sheet now counts as a cancel, so the operation waiting on it finishes instead of hanging.
Adds a Full customization example to the SwiftUI sample. It replaces the first screen with .pickerContent { state in }, every pushed screen through its content slot and the signed-in screen with .signedInContent, while the library keeps auth, navigation, MFA and reauthentication.

Doggo, Grid and Coffee Spark themes come from the iOS Landing frames of the Firebase auth flows Figma file and switch live from any screen. Screens the frames don't cover follow the FirebaseUI-Android demo's copy. The theming modifiers restyle the stock screens left in the flow.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the authentication views in FirebaseAuthSwiftUI to support full UI customization by introducing customizable screen wrappers (such as EmailAuthScreen and MFAEnrolmentScreen) and extracting the stock UI into default content views. It also adds a comprehensive multi-theme customization example to the sample application. The review feedback highlights critical improvements, including reusing a static CIContext for QR code generation to prevent performance degradation, and replacing a force cast with safe optional casting to avoid potential runtime crashes.

Comment on lines +453 to +472
private func generateQRCode(from string: String) -> UIImage? {
let data = Data(string.utf8)

guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
filter.setValue(data, forKey: "inputMessage")
filter.setValue("H", forKey: "inputCorrectionLevel")

guard let ciImage = filter.outputImage else { return nil }

// Scale up the QR code for better quality
let transform = CGAffineTransform(scaleX: 10, y: 10)
let scaledImage = ciImage.transformed(by: transform)

let context = CIContext()
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
return nil
}

return UIImage(cgImage: cgImage)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Creating a new CIContext on every invocation of generateQRCode(from:) is highly inefficient and can cause UI stuttering/performance degradation because CIContext is expensive to instantiate. It is recommended to reuse a single shared/static CIContext instance.

  private static let qrContext = CIContext()

  private func generateQRCode(from string: String) -> UIImage? {
    let data = Data(string.utf8)

    guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
    filter.setValue(data, forKey: "inputMessage")
    filter.setValue("H", forKey: "inputCorrectionLevel")

    guard let ciImage = filter.outputImage else { return nil }

    // Scale up the QR code for better quality
    let transform = CGAffineTransform(scaleX: 10, y: 10)
    let scaledImage = ciImage.transformed(by: transform)

    guard let cgImage = Self.qrContext.createCGImage(scaledImage, from: scaledImage.extent) else {
      return nil
    }

    return UIImage(cgImage: cgImage)
  }

Comment on lines +124 to +133
if factor.factorID == PhoneMultiFactorID {
let phoneInfo = factor as! PhoneMultiFactorInfo
Text("SMS: \(phoneInfo.phoneNumber)")
.authFont(.caption)
.foregroundColor(.secondary)
} else {
Text("Authenticator App")
.authFont(.caption)
.foregroundColor(.secondary)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Force casting factor as! PhoneMultiFactorInfo can lead to runtime crashes if the factor is not of the expected type. It is safer and more idiomatic to use safe optional casting (as?) to prevent potential crashes.

        if let phoneInfo = factor as? PhoneMultiFactorInfo {
          Text("SMS: \(phoneInfo.phoneNumber)")
            .authFont(.caption)
            .foregroundColor(.secondary)
        } else {
          Text("Authenticator App")
            .authFont(.caption)
            .foregroundColor(.secondary)
        }

Comment on lines +337 to +348
private struct DemoQRCode: View {
let image: UIImage

init?(url: URL) {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(url.absoluteString.utf8)
guard let output = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)),
let cgImage = CIContext().createCGImage(output, from: output.extent) else {
return nil
}
image = UIImage(cgImage: cgImage)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instantiating a new CIContext inside the initializer of DemoQRCode is inefficient because SwiftUI views are frequently recreated. Reusing a single static CIContext avoids this overhead.

Suggested change
private struct DemoQRCode: View {
let image: UIImage
init?(url: URL) {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(url.absoluteString.utf8)
guard let output = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)),
let cgImage = CIContext().createCGImage(output, from: output.extent) else {
return nil
}
image = UIImage(cgImage: cgImage)
}
private struct DemoQRCode: View {
private static let qrContext = CIContext()
let image: UIImage
init?(url: URL) {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(url.absoluteString.utf8)
guard let output = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)),
let cgImage = Self.qrContext.createCGImage(output, from: output.extent) else {
return nil
}
image = UIImage(cgImage: cgImage)
}

@demolaf
demolaf force-pushed the sample/full-customization branch from 6cbeed7 to 074f290 Compare October 22, 2026 14:13
Starts sign-in from the other-providers sheet after it dismisses, creates the example's AuthService when it appears rather than when ContentView builds the link, anchors the mascot to the form's trailing edge, generates the TOTP QR code once per URL, disables Remove while an unenrolment runs, gives sheet providers an icon, and ships the spark background at 2x only.
@demolaf
demolaf force-pushed the sample/full-customization branch from 1847902 to 995a908 Compare October 22, 2026 14:20
The form and footer now keep the Figma frame's 332 and 334 point widths, centred, instead of fixed 49 and 48 point insets that only fit the 430 point frame and squeezed the form on narrower phones.
The background sat in the scroll view's .background, so it shrank with the scroll view and left white edges once the form had a maximum width. It is now a ZStack layer behind a full-width scroll view.
The remaining providers open from a pill styled like Google's instead of a text link, so the three buttons keep Figma's 12 point spacing. The flow switch gets a 44 point tap target, and text links get a taller tap area without changing the layout.
…sheet

Providers the Figma frames don't draw now appear as the library's AuthProviderButton with each provider's own label and ProviderStyle, instead of improvised pills. The action still goes through AuthMethodPickerState.select once the sheet has closed.
The switch toggles the email form, so it now sits right below the form and above the other sign-in methods divider, rather than under the provider buttons where the Figma frames place it.
The switch text now sits 24 points from the form above and the divider below in every theme. The per-theme form-to-divider spacing it replaced is removed.
The enrolment steps move into DemoMFAEnrolmentContent so a preview can render the TOTP setup step from a stub MFAEnrollmentContentState. The Auth emulator only enrols SMS factors, so the step can't be reached against it.
Every demo screen now uses one content width: 32 points from the sheet's edges, capped at 480 points. The Figma frames' 332 point form left 49 points each side on a 430 point phone, which was too much.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant