From 6eb7e2cc85add0361a37254c312769e23fa6cdeb Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:26:09 +0100 Subject: [PATCH 1/4] feat(auth): add content slots to phone number and verification code screens Add EnterPhoneNumberScreen and EnterVerificationCodeScreen, generic over content that receives a PhoneNumberContentState or VerificationCodeContentState. The screens own the input state and the send, verify, resend and change-number actions; the content only draws. DefaultEnterPhoneNumberContent and DefaultEnterVerificationCodeContent hold the stock markup, and the existing views render them through the new screens. The verification code screen adds a resend button with a 60 second countdown. Verifying now goes through signIn(credentials:), so an MFA challenge reaches the resolution screen instead of being dropped. isLoading also covers sending the code, which previously showed no progress and allowed repeated taps. --- .../DefaultEnterPhoneNumberContent.swift | 74 ++++++++ .../DefaultEnterVerificationCodeContent.swift | 91 ++++++++++ .../Views/EnterPhoneNumberScreen.swift | 108 ++++++++++++ .../Sources/Views/EnterPhoneNumberView.swift | 69 +------- .../Views/EnterVerificationCodeScreen.swift | 163 ++++++++++++++++++ .../Views/EnterVerificationCodeView.swift | 79 +-------- 6 files changed, 441 insertions(+), 143 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift new file mode 100644 index 0000000000..1b5745afa8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift @@ -0,0 +1,74 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EnterPhoneNumberScreen``. +@MainActor +public struct DefaultEnterPhoneNumberContent: View { + public init(state: PhoneNumberContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PhoneNumberContentState + + public var body: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: state.phoneNumber, + label: authService.string.phoneFieldLabel, + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber, + ], + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + + Button(action: state.sendCode) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift new file mode 100644 index 0000000000..4802e87953 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -0,0 +1,91 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EnterVerificationCodeScreen``. +@MainActor +public struct DefaultEnterVerificationCodeContent: View { + public init(state: VerificationCodeContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: VerificationCodeContentState + + public var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(authService.string.sentCodeMessage(phoneNumber: state.fullPhoneNumber)) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: state.changeNumber) { + Text(authService.string.changeNumberButtonLabel) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + + Button(action: state.resendCode) { + Text(resendLabel) + .authFont(.body) + .frame(maxWidth: .infinity) + } + .disabled(state.isLoading || state.resendCountdown > 0) + .accessibilityIdentifier("resend-code-button") + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.large) + .padding(.horizontal) + } + + private var resendLabel: String { + guard state.resendCountdown > 0 else { return authService.string.resendCodeLabel } + let remaining = Duration.seconds(state.resendCountdown) + .formatted(.time(pattern: .minuteSecond)) + return "\(authService.string.resendCodeLabel) (\(remaining))" + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift new file mode 100644 index 0000000000..5ffc5af9d8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -0,0 +1,108 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the phone number screen, passed to ``EnterPhoneNumberScreen``'s +/// content. +public struct PhoneNumberContentState { + /// The national phone number, without the dial code. + public let phoneNumber: Binding + public let selectedCountry: Binding + /// Sends a verification code to the full phone number, then pushes + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. + public let sendCode: () -> Void + public let isValid: Bool + public let isLoading: Bool + /// The localized message of the last failed attempt, if any. + public let errorMessage: String? +} + +/// The phone number screen, with its content drawn by you. +/// +/// The screen sends the code, navigates, and reports errors. Your content only draws the UI from +/// the ``PhoneNumberContentState`` it receives. Wrap ``DefaultEnterPhoneNumberContent`` to keep +/// the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// EnterPhoneNumberScreen { state in +/// TextField("Phone number", text: state.phoneNumber) +/// Button("Send code", action: state.sendCode) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterPhoneNumberScreen: View { + public init(@ViewBuilder content: @escaping (PhoneNumberContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var phoneNumber: String = "" + @State private var selectedCountry: CountryData = .default + @State private var isSending = false + @State private var errorMessage: String? + + private let content: (PhoneNumberContentState) -> Content + + public var body: some View { + content(PhoneNumberContentState( + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + sendCode: sendCode, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + } + + private var isValid: Bool { + !phoneNumber.isEmpty + } + + private var isLoading: Bool { + isSending || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard isValid, !isLoading else { return } + Task { + isSending = true + errorMessage = nil + defer { isSending = false } + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + authService.navigator.push(.enterVerificationCode( + verificationID: id, + fullPhoneNumber: fullPhoneNumber + )) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 54e41c1fa8..bfb7664412 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -12,79 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI struct EnterPhoneNumberView: View { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var phoneNumber: String = "" - @State private var selectedCountry: CountryData = .default - var body: some View { - VStack(spacing: 16) { - Text(authService.string.enterPhoneNumberPlaceholder) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top) - - AuthTextField( - text: $phoneNumber, - label: authService.string.phoneFieldLabel, - prompt: authService.string.enterPhoneNumberPlaceholder, - keyboardType: .phonePad, - contentType: .telephoneNumber, - validations: [ - FormValidators.phoneNumber, - ], - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !(authService.authenticationState == .authenticating) - ) - } - - Button(action: { - Task { - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) - authService.navigator.push(.enterVerificationCode( - verificationID: id, - fullPhoneNumber: fullPhoneNumber - )) - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) - .padding(.top, 8) - - Spacer() - } - .navigationTitle(authService.string.phoneSignInTitle) - .padding(.horizontal) + EnterPhoneNumberScreen { DefaultEnterPhoneNumberContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift new file mode 100644 index 0000000000..0054c44b0d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -0,0 +1,163 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +/// Seconds between sending a code and allowing a resend. +private let resendDelay: TimeInterval = 60 + +/// The state and actions of the verification code screen, passed to +/// ``EnterVerificationCodeScreen``'s content. +public struct VerificationCodeContentState { + public let verificationCode: Binding + /// Signs in with the code, routing MFA and account conflicts to their handlers. + public let verify: () -> Void + /// Sends a new code. Does nothing while ``resendCountdown`` is above zero. + public let resendCode: () -> Void + /// Pops back to the phone number screen. + public let changeNumber: () -> Void + /// The number the code was sent to, including the dial code. + public let fullPhoneNumber: String + /// Seconds left before ``resendCode`` is available; `0` when it is. + public let resendCountdown: Int + public let isValid: Bool + public let isLoading: Bool + /// The localized message of the last failed attempt, if any. + public let errorMessage: String? +} + +/// The verification code screen, with its content drawn by you. +/// +/// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account +/// conflicts and errors. Your content only draws the UI from the +/// ``VerificationCodeContentState`` it receives. Wrap ``DefaultEnterVerificationCodeContent`` to +/// keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case let .enterVerificationCode(verificationID, fullPhoneNumber): +/// EnterVerificationCodeScreen( +/// verificationID: verificationID, +/// fullPhoneNumber: fullPhoneNumber +/// ) { state in +/// TextField("Code", text: state.verificationCode) +/// Button("Verify", action: state.verify) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterVerificationCodeScreen: View { + public init(verificationID: String, + fullPhoneNumber: String, + @ViewBuilder content: @escaping (VerificationCodeContentState) -> Content) { + _verificationID = State(initialValue: verificationID) + self.fullPhoneNumber = fullPhoneNumber + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var verificationID: String + @State private var verificationCode: String = "" + @State private var resendAvailableAt = Date().addingTimeInterval(resendDelay) + @State private var resendCountdown = Int(resendDelay) + @State private var isWorking = false + @State private var errorMessage: String? + + private let fullPhoneNumber: String + private let content: (VerificationCodeContentState) -> Content + + public var body: some View { + content(VerificationCodeContentState( + verificationCode: $verificationCode, + verify: verify, + resendCode: resendCode, + changeNumber: { authService.navigator.pop() }, + fullPhoneNumber: fullPhoneNumber, + resendCountdown: resendCountdown, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .task(id: resendAvailableAt) { + repeat { + resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + try? await Task.sleep(for: .seconds(1)) + } while resendCountdown > 0 && !Task.isCancelled + } + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func verify() { + guard isValid, !isLoading else { return } + Task { + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + let outcome = try await authService.signIn(credentials: credential) + if case let .mfaRequired(mfaInfo) = outcome, let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } + authService.navigator.clear() + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func resendCode() { + guard resendCountdown == 0, !isLoading else { return } + Task { + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + verificationCode = "" + resendAvailableAt = Date().addingTimeInterval(resendDelay) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 3a644e713c..4924946f3c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -12,90 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor struct EnterVerificationCodeView: View { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var verificationCode: String = "" - let verificationID: String let fullPhoneNumber: String var body: some View { - @Bindable var authService = authService - VStack(spacing: 32) { - VStack(spacing: 16) { - VStack(spacing: 8) { - Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - authService.navigator.pop() - } label: { - Text(authService.string.changeNumberButtonLabel) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - - Button(action: { - Task { - do { - try await authService.signInWithPhoneNumber( - verificationID: verificationID, - verificationCode: verificationCode - ) - authService.navigator.clear() - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyAndSignInButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) - } - - Spacer() - } - .navigationTitle(authService.string.enterVerificationCodeTitle) - .navigationBarTitleDisplayMode(.large) - .padding(.horizontal) + EnterVerificationCodeScreen( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) { DefaultEnterVerificationCodeContent(state: $0) } } } From 0477475b28e3ec28be44122eff01e6eea16f817c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:16:35 +0100 Subject: [PATCH 2/4] fix(auth): tighten phone screen actions and match the picker state API Set the in-flight flag before starting each action's Task, so a quick second tap can't send two codes or overlap verify and resend. Only clear the navigator on .signedIn, so an MFA challenge with no handler doesn't drop the user back to the picker. Type the state closures as @MainActor and give both state structs public initializers, matching AuthMethodPickerState. Clear errorMessage when the input changes, document every field, disable Change number while loading, and show the resend countdown as its own text instead of a concatenated string. --- .../DefaultEnterVerificationCodeContent.swift | 20 ++--- .../Views/EnterPhoneNumberScreen.swift | 43 +++++++++-- .../Views/EnterVerificationCodeScreen.swift | 77 ++++++++++++++----- 3 files changed, 102 insertions(+), 38 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift index 4802e87953..dd145f4e49 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -40,6 +40,7 @@ public struct DefaultEnterVerificationCodeContent: View { .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) } + .disabled(state.isLoading) } .padding(.bottom) .frame(maxWidth: .infinity, alignment: .leading) @@ -67,9 +68,15 @@ public struct DefaultEnterVerificationCodeContent: View { .disabled(state.isLoading || !state.isValid) Button(action: state.resendCode) { - Text(resendLabel) - .authFont(.body) - .frame(maxWidth: .infinity) + HStack(spacing: 4) { + Text(authService.string.resendCodeLabel) + if state.resendCountdown > 0 { + Text(Duration.seconds(state.resendCountdown), format: .time(pattern: .minuteSecond)) + .monospacedDigit() + } + } + .authFont(.body) + .frame(maxWidth: .infinity) } .disabled(state.isLoading || state.resendCountdown > 0) .accessibilityIdentifier("resend-code-button") @@ -81,11 +88,4 @@ public struct DefaultEnterVerificationCodeContent: View { .navigationBarTitleDisplayMode(.large) .padding(.horizontal) } - - private var resendLabel: String { - guard state.resendCountdown > 0 else { return authService.string.resendCodeLabel } - let remaining = Duration.seconds(state.resendCountdown) - .formatted(.time(pattern: .minuteSecond)) - return "\(authService.string.resendCodeLabel) (\(remaining))" - } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift index 5ffc5af9d8..2b8535d3a5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -20,14 +20,39 @@ import SwiftUI public struct PhoneNumberContentState { /// The national phone number, without the dial code. public let phoneNumber: Binding + + /// The country whose dial code prefixes ``phoneNumber``. public let selectedCountry: Binding + /// Sends a verification code to the full phone number, then pushes - /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. - public let sendCode: () -> Void + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Whether ``phoneNumber`` is non-empty. public let isValid: Bool + + /// Whether a code is being sent, or the ``AuthService`` is signing in. public let isLoading: Bool - /// The localized message of the last failed attempt, if any. + + /// The localized message of the last failed attempt. It clears when the number or country + /// changes or the next attempt starts. The sheet's error alert still shows for the same failure. public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(phoneNumber: Binding, + selectedCountry: Binding, + sendCode: @escaping @MainActor () -> Void, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.phoneNumber = phoneNumber + self.selectedCountry = selectedCountry + self.sendCode = sendCode + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } } /// The phone number screen, with its content drawn by you. @@ -62,7 +87,7 @@ public struct EnterPhoneNumberScreen: View { @Environment(\.reportError) private var reportError @State private var phoneNumber: String = "" @State private var selectedCountry: CountryData = .default - @State private var isSending = false + @State private var isWorking = false @State private var errorMessage: String? private let content: (PhoneNumberContentState) -> Content @@ -76,6 +101,8 @@ public struct EnterPhoneNumberScreen: View { isLoading: isLoading, errorMessage: errorMessage )) + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } } private var isValid: Bool { @@ -83,15 +110,15 @@ public struct EnterPhoneNumberScreen: View { } private var isLoading: Bool { - isSending || authService.authenticationState == .authenticating + isWorking || authService.authenticationState == .authenticating } private func sendCode() { guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isSending = true - errorMessage = nil - defer { isSending = false } + defer { isWorking = false } do { let fullPhoneNumber = selectedCountry.dialCode + phoneNumber let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift index 0054c44b0d..5b681ccc8e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -21,29 +21,64 @@ private let resendDelay: TimeInterval = 60 /// The state and actions of the verification code screen, passed to /// ``EnterVerificationCodeScreen``'s content. public struct VerificationCodeContentState { + /// The code the user typed. public let verificationCode: Binding - /// Signs in with the code, routing MFA and account conflicts to their handlers. - public let verify: () -> Void - /// Sends a new code. Does nothing while ``resendCountdown`` is above zero. - public let resendCode: () -> Void + + /// Signs in with the code, routing MFA and account conflicts to their handlers. Does nothing + /// unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let verify: @MainActor () -> Void + + /// Sends a new code and clears ``verificationCode``. Does nothing while ``resendCountdown`` is + /// above zero or ``isLoading`` is `true`. + public let resendCode: @MainActor () -> Void + /// Pops back to the phone number screen. - public let changeNumber: () -> Void + public let changeNumber: @MainActor () -> Void + /// The number the code was sent to, including the dial code. public let fullPhoneNumber: String + /// Seconds left before ``resendCode`` is available; `0` when it is. public let resendCountdown: Int + + /// Whether ``verificationCode`` has 6 digits. public let isValid: Bool + + /// Whether a verify or resend is in flight, or the ``AuthService`` is signing in. public let isLoading: Bool - /// The localized message of the last failed attempt, if any. + + /// The localized message of the last failed verify or resend. It clears when the code changes + /// or the next attempt starts. The sheet's error alert still shows for the same failure. public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(verificationCode: Binding, + verify: @escaping @MainActor () -> Void, + resendCode: @escaping @MainActor () -> Void, + changeNumber: @escaping @MainActor () -> Void, + fullPhoneNumber: String, + resendCountdown: Int, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.verificationCode = verificationCode + self.verify = verify + self.resendCode = resendCode + self.changeNumber = changeNumber + self.fullPhoneNumber = fullPhoneNumber + self.resendCountdown = resendCountdown + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } } /// The verification code screen, with its content drawn by you. /// /// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account -/// conflicts and errors. Your content only draws the UI from the -/// ``VerificationCodeContentState`` it receives. Wrap ``DefaultEnterVerificationCodeContent`` to -/// keep the stock markup. +/// conflicts and errors. It reads `verificationID` once; codes it resends replace it +/// internally. Your content only draws the UI from the ``VerificationCodeContentState`` it +/// receives. Wrap ``DefaultEnterVerificationCodeContent`` to keep the stock markup. /// /// Example: /// ```swift @@ -100,11 +135,13 @@ public struct EnterVerificationCodeScreen: View { isLoading: isLoading, errorMessage: errorMessage )) + .onChange(of: verificationCode) { errorMessage = nil } .task(id: resendAvailableAt) { - repeat { + while !Task.isCancelled { resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + guard resendCountdown > 0 else { return } try? await Task.sleep(for: .seconds(1)) - } while resendCountdown > 0 && !Task.isCancelled + } } } @@ -118,19 +155,19 @@ public struct EnterVerificationCodeScreen: View { private func verify() { guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isWorking = true - errorMessage = nil defer { isWorking = false } do { let credential = PhoneAuthProvider.provider() .credential(withVerificationID: verificationID, verificationCode: verificationCode) - let outcome = try await authService.signIn(credentials: credential) - if case let .mfaRequired(mfaInfo) = outcome, let onMFA = mfaHandler { - onMFA(mfaInfo) - return + switch try await authService.signIn(credentials: credential) { + case let .mfaRequired(mfaInfo): + mfaHandler?(mfaInfo) + case .signedIn: + authService.navigator.clear() } - authService.navigator.clear() } catch { if case let AuthServiceError.accountConflict(ctx) = error, let onConflict = accountConflictHandler { @@ -146,9 +183,9 @@ public struct EnterVerificationCodeScreen: View { private func resendCode() { guard resendCountdown == 0, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isWorking = true - errorMessage = nil defer { isWorking = false } do { verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) From 3faec1ddae0401619e6705e1f2d2b986c1bf34f2 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:44:25 +0100 Subject: [PATCH 3/4] fix(auth): let the resend code button inherit the app font when no theme is set --- .../Sources/Views/DefaultEnterVerificationCodeContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift index dd145f4e49..9b724a838d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -75,7 +75,7 @@ public struct DefaultEnterVerificationCodeContent: View { .monospacedDigit() } } - .authFont(.body) + .authCustomFont(.body) .frame(maxWidth: .infinity) } .disabled(state.isLoading || state.resendCountdown > 0) From 8774d7e3146a3864031b8063ecf366c58b4918fd Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:38:57 +0100 Subject: [PATCH 4/4] feat(auth): add signedInContent slot to AuthPickerView 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. --- .../Sources/Views/AuthPickerContent.swift | 48 +++- .../Sources/Views/AuthPickerView.swift | 68 ++++- .../Views/DefaultSignedInContent.swift | 94 +++++++ .../Views/ReauthenticationModifier.swift | 13 +- .../Sources/Views/SignedInScreen.swift | 247 ++++++++++++++++++ .../Sources/Views/SignedInView.swift | 206 +-------------- 6 files changed, 449 insertions(+), 227 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 32852e8c47..7705768b5c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -17,24 +17,28 @@ import FirebaseAuthUIComponents import SwiftUI /// An ``AuthPickerView`` with custom sheet content, returned by -/// `AuthPickerView`'s `pickerContent` and ``AuthPickerView/pickerDestination(_:)`` modifiers. +/// `AuthPickerView`'s `pickerContent`, ``AuthPickerView/pickerDestination(_:)`` and +/// ``AuthPickerView/signedInContent(_:)`` modifiers. /// /// It owns the authentication sheet, its `NavigationStack`, error alerts, and MFA and -/// account-conflict handling; only the first screen and the pushed screens come from the slots. -/// You don't create it directly. +/// account-conflict handling; only the first screen, the signed-in screen and the pushed screens +/// come from the slots. You don't create it directly. @MainActor public struct AuthPickerContent< Content: View, PickerContent: View, - DestinationContent: View + DestinationContent: View, + SignedInContent: View >: View { init(content: @escaping () -> Content, pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, destination: @escaping (AuthView) -> DestinationContent, + signedInContent: @escaping (SignedInContentState) -> SignedInContent, showsPickerTitle: Bool = true) { self.content = content self.pickerContent = pickerContent self.destination = destination + self.signedInContent = signedInContent self.showsPickerTitle = showsPickerTitle } @@ -42,6 +46,7 @@ public struct AuthPickerContent< private let content: () -> Content private let pickerContent: (AuthMethodPickerState) -> PickerContent private let destination: (AuthView) -> DestinationContent + private let signedInContent: (SignedInContentState) -> SignedInContent /// `false` when the whole first screen is custom, so the library's large title doesn't sit /// above the app's own heading. The close button stays so the sheet can still be dismissed. private let showsPickerTitle: Bool @@ -89,13 +94,13 @@ public struct AuthPickerContent< } } - /// The sheet's first screen. The slot only replaces the signed-out screen, so signed-in users - /// always reach ``SignedInView`` and the progress overlay always shows while signing in. + /// The sheet's first screen: the signed-in slot for signed-in users, otherwise the picker slot. + /// The progress overlay always shows while signing in, whichever slot is on screen. @ViewBuilder private var root: some View { VStack { if authService.authenticationState == .authenticated { - SignedInView() + SignedInScreen(content: signedInContent) } else { AuthMethodPickerStateReader { state in pickerContent(state) @@ -122,11 +127,12 @@ public struct AuthPickerContent< /// the no-argument `pickerContent` on ``AuthPickerView``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent - ) -> AuthPickerContent { - AuthPickerContent( + ) -> AuthPickerContent { + AuthPickerContent( content: content, pickerContent: { _ in pickerContent() }, destination: destination, + signedInContent: signedInContent, showsPickerTitle: true ) } @@ -135,11 +141,12 @@ public struct AuthPickerContent< /// ``AuthMethodPickerState``. See the matching `pickerContent` on ``AuthPickerView``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent - ) -> AuthPickerContent { - AuthPickerContent( + ) -> AuthPickerContent { + AuthPickerContent( content: content, pickerContent: pickerContent, destination: destination, + signedInContent: signedInContent, showsPickerTitle: false ) } @@ -148,11 +155,26 @@ public struct AuthPickerContent< /// ``AuthPickerView/pickerDestination(_:)``. public func pickerDestination( @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent - ) -> AuthPickerContent { - AuthPickerContent( + ) -> AuthPickerContent { + AuthPickerContent( content: content, pickerContent: pickerContent, destination: destination, + signedInContent: signedInContent, + showsPickerTitle: showsPickerTitle + ) + } + + /// Replaces the signed-in screen of the authentication sheet. See + /// ``AuthPickerView/signedInContent(_:)``. + public func signedInContent( + @ViewBuilder _ signedInContent: @escaping (SignedInContentState) -> NewSignedInContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination, + signedInContent: signedInContent, showsPickerTitle: showsPickerTitle ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index db685ca1fc..59f91f9e0c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -20,8 +20,9 @@ import SwiftUI /// Wraps your app content and presents the authentication sheet whenever /// `AuthService.isPresented` is `true`. /// -/// Use the `pickerContent` modifiers and ``pickerDestination(_:)`` to replace the sheet's first screen or -/// any pushed screen while the library keeps driving navigation, MFA and account conflicts. +/// Use the `pickerContent` modifiers, ``pickerDestination(_:)`` and ``signedInContent(_:)`` to +/// replace the sheet's first screen, any pushed screen or the signed-in screen while the library +/// keeps driving navigation, MFA and account conflicts. /// /// Example: /// ```swift @@ -42,12 +43,14 @@ public struct AuthPickerView: View { AuthPickerContent( content: content, pickerContent: { _ in DefaultAuthPickerContent() }, - destination: { DefaultAuthPickerDestination(screen: $0) } + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) } ) } - /// Replaces the signed-out first screen of the authentication sheet. The library still shows - /// ``SignedInView`` to signed-in users and its progress overlay while signing in. + /// Replaces the signed-out first screen of the authentication sheet. Signed-in users still see + /// ``DefaultSignedInContent`` unless you replace it with ``signedInContent(_:)``, and the + /// progress overlay still shows while signing in. /// /// ```swift /// AuthPickerView { authenticatedApp } @@ -58,11 +61,17 @@ public struct AuthPickerView: View { /// ``` public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent - ) -> AuthPickerContent { + ) -> AuthPickerContent< + Content, + NewPickerContent, + DefaultAuthPickerDestination, + DefaultSignedInContent + > { AuthPickerContent( content: content, pickerContent: { _ in pickerContent() }, - destination: { DefaultAuthPickerDestination(screen: $0) } + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) } ) } @@ -84,11 +93,17 @@ public struct AuthPickerView: View { /// ``` public func pickerContent( @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent - ) -> AuthPickerContent { + ) -> AuthPickerContent< + Content, + NewPickerContent, + DefaultAuthPickerDestination, + DefaultSignedInContent + > { AuthPickerContent( content: content, pickerContent: pickerContent, destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) }, showsPickerTitle: false ) } @@ -115,12 +130,45 @@ public struct AuthPickerView: View { ) -> AuthPickerContent< Content, DefaultAuthPickerContent, - NewDestinationContent + NewDestinationContent, + DefaultSignedInContent + > { + AuthPickerContent( + content: content, + pickerContent: { _ in DefaultAuthPickerContent() }, + destination: destination, + signedInContent: { DefaultSignedInContent(state: $0) } + ) + } + + /// Replaces the screen the authentication sheet shows to signed-in users. Your content receives + /// a ``SignedInContentState``: the library still signs out, deletes the account behind its + /// confirmation and reauthentication, sends the verification email and navigates. Wrap + /// ``DefaultSignedInContent`` to keep the stock markup. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .signedInContent { state in + /// MyAccountView( + /// email: state.user?.email, + /// onDeleteAccount: state.deleteAccount, + /// onSignOut: state.signOut + /// ) + /// } + /// ``` + public func signedInContent( + @ViewBuilder _ signedInContent: @escaping (SignedInContentState) -> NewSignedInContent + ) -> AuthPickerContent< + Content, + DefaultAuthPickerContent, + DefaultAuthPickerDestination, + NewSignedInContent > { AuthPickerContent( content: content, pickerContent: { _ in DefaultAuthPickerContent() }, - destination: destination + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: signedInContent ) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift new file mode 100644 index 0000000000..c2ddf3122f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift @@ -0,0 +1,94 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// The stock signed-in screen, the default content of ``AuthPickerView/signedInContent(_:)``. +/// Wrap it to keep the stock markup. +@MainActor +public struct DefaultSignedInContent: View { + public init(state: SignedInContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: SignedInContentState + + public var body: some View { + VStack { + Text(authService.string.signedInTitle) + .authFont(.largeTitle) + .fontWeight(.bold) + .padding() + .accessibilityIdentifier("signed-in-text") + Text( + "\(state.user?.email ?? state.user?.displayName ?? state.user?.phoneNumber ?? "")" + ) + .authCustomFont(.body) + if !state.isEmailVerified { + Button(action: state.verifyEmail) { + Text(authService.string.sendEmailVerificationButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("verify-email-button") + } + Button(action: state.updatePassword) { + Text(authService.string.updatePasswordButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("update-password-button") + + if state.isMFAEnabled { + Button(action: state.goToMFAManagement) { + Text(authService.string.manageTwoFactorAuthenticationLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("mfa-management-button") + } + + Button(action: state.deleteAccount) { + Text(authService.string.deleteAccountButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("delete-account-button") + + Button(action: state.signOut) { + Text(authService.string.signOutButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("sign-out-button") + } + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift index b20b23a59f..3cb7bd8a1a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -55,7 +55,7 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for phone reauthentication - .sheet(isPresented: $coordinator.showingPhoneReauth) { + .sheet(isPresented: $coordinator.showingPhoneReauth, onDismiss: cancelIfPending) { if case let .phone(context) = coordinator.reauthContext { PhoneReauthView( phoneNumber: context.phoneNumber, @@ -64,7 +64,7 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for email reauthentication - .sheet(isPresented: $coordinator.showingEmailPasswordPrompt) { + .sheet(isPresented: $coordinator.showingEmailPasswordPrompt, onDismiss: cancelIfPending) { if case let .email(context) = coordinator.reauthContext { EmailReauthView( email: context.email, @@ -89,7 +89,7 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for email link reauthentication - .sheet(isPresented: $coordinator.showingEmailLinkReauth) { + .sheet(isPresented: $coordinator.showingEmailLinkReauth, onDismiss: cancelIfPending) { if case let .emailLink(context) = coordinator.reauthContext { EmailLinkReauthView( email: context.email, @@ -99,6 +99,13 @@ struct ReauthenticationModifier: ViewModifier { } } + /// Treats a sheet swiped away mid-flow as a cancel, so the waiting operation finishes. + private func cancelIfPending() { + if coordinator.reauthContext != nil { + coordinator.reauthCancelled() + } + } + private func performReauth() { Task { do { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift new file mode 100644 index 0000000000..831ec64adc --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift @@ -0,0 +1,247 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +/// The state and actions of the signed-in screen, passed to the content of +/// ``AuthPickerView/signedInContent(_:)``. +public struct SignedInContentState { + /// Signs the user out. Does nothing while ``isLoading`` is `true`. + public let signOut: @MainActor () -> Void + + /// Asks the user to confirm, then deletes the account, reauthenticating first when Firebase + /// requires it. Does nothing while ``isLoading`` is `true`. + public let deleteAccount: @MainActor () -> Void + + /// Pushes ``AuthView/updatePassword``. + public let updatePassword: @MainActor () -> Void + + /// Sends a verification email to the user, then shows an alert telling them to tap the link. + /// Does nothing while ``isLoading`` is `true`. + public let verifyEmail: @MainActor () -> Void + + /// Pushes ``AuthView/mfaManagement``. Offer it only when ``isMFAEnabled`` is `true`. + public let goToMFAManagement: @MainActor () -> Void + + /// The signed-in user. + public let user: User? + + /// Whether the user's email is verified; `true` when there is no user. + public let isEmailVerified: Bool + + /// Whether ``AuthConfiguration/mfaEnabled`` is set. Offer ``goToMFAManagement`` only when it is + /// `true`. + public let isMFAEnabled: Bool + + /// Whether a sign out, account deletion or verification email is in flight. + public let isLoading: Bool + + /// The localized message of the last failed sign out, account deletion or verification email. + /// It clears when the next of those starts, and stays `nil` when the user cancels + /// reauthentication. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(signOut: @escaping @MainActor () -> Void, + deleteAccount: @escaping @MainActor () -> Void, + updatePassword: @escaping @MainActor () -> Void, + verifyEmail: @escaping @MainActor () -> Void, + goToMFAManagement: @escaping @MainActor () -> Void, + user: User?, + isEmailVerified: Bool, + isMFAEnabled: Bool, + isLoading: Bool, + errorMessage: String?) { + self.signOut = signOut + self.deleteAccount = deleteAccount + self.updatePassword = updatePassword + self.verifyEmail = verifyEmail + self.goToMFAManagement = goToMFAManagement + self.user = user + self.isEmailVerified = isEmailVerified + self.isMFAEnabled = isMFAEnabled + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// Runs the signed-in screen's actions, delete confirmation, reauthentication and alerts, and +/// draws its content from the ``SignedInContentState`` it builds. +@MainActor +struct SignedInScreen: View { + init(@ViewBuilder content: @escaping (SignedInContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var showDeleteConfirmation = false + @State private var showEmailVerificationSent = false + @State private var reauthCoordinator = ReauthenticationCoordinator() + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (SignedInContentState) -> Content + + var body: some View { + // A single container, so the sheet and alerts below attach once even when the content is + // several views. + VStack { + content(state) + } + .withReauthentication(coordinator: reauthCoordinator) + .sheet(isPresented: $showDeleteConfirmation) { + DeleteAccountConfirmationSheet( + onConfirm: { + showDeleteConfirmation = false + deleteAccount() + }, + onCancel: { + showDeleteConfirmation = false + } + ) + .presentationDetents([.medium]) + } + .alert( + authService.string.verifyEmailSheetMessage, + isPresented: $showEmailVerificationSent + ) { + Button(authService.string.okButtonLabel) { + showEmailVerificationSent = false + } + } message: { + Text(authService.string.emailVerificationTapLinkMessage) + } + } + + private var state: SignedInContentState { + SignedInContentState( + signOut: signOut, + deleteAccount: { + if !isWorking { + showDeleteConfirmation = true + } + }, + updatePassword: { authService.navigator.push(.updatePassword) }, + verifyEmail: verifyEmail, + goToMFAManagement: { authService.navigator.push(.mfaManagement) }, + user: authService.currentUser, + isEmailVerified: authService.currentUser?.isEmailVerified != false, + isMFAEnabled: authService.configuration.mfaEnabled, + isLoading: isWorking, + errorMessage: errorMessage + ) + } + + private func signOut() { + perform { try await authService.signOut() } + } + + private func verifyEmail() { + perform { + try await authService.sendEmailVerification() + showEmailVerificationSent = true + } + } + + private func deleteAccount() { + perform { + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.deleteUser() + } + } + } + + private func perform(_ action: @escaping @MainActor () async throws -> Void) { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await action() + } catch { + if case AuthServiceError.signInCancelled = error { + // The user dismissed reauthentication; the sheet still reports it, as before. + } else { + errorMessage = authService.string.localizedErrorMessage(for: error) + } + reportError?(error) + } + } + } +} + +private struct DeleteAccountConfirmationSheet: View { + @Environment(AuthService.self) private var authService + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + Text("Delete Account?") + .authFont(.title) + .fontWeight(.bold) + + Text( + "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + VStack(spacing: 12) { + Button { + onConfirm() + } label: { + Text("Delete Account") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("confirm-delete-button") + + Button { + onCancel() + } label: { + Text("Cancel") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("cancel-delete-button") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 45ebffb82e..4319805798 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -15,213 +15,17 @@ import FirebaseCore import SwiftUI +/// The stock signed-in screen: ``DefaultSignedInContent`` with the library's account actions. The +/// authentication sheet shows it to signed-in users unless you replace it with +/// ``AuthPickerView/signedInContent(_:)``. @MainActor public struct SignedInView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var showDeleteConfirmation = false - @State private var showEmailVerificationSent = false - @State private var reauthCoordinator = ReauthenticationCoordinator() - - private func sendEmailVerification() async throws { - do { - try await authService.sendEmailVerification() - showEmailVerificationSent = true - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } + public init() {} } extension SignedInView: View { public var body: some View { - VStack { - Text(authService.string.signedInTitle) - .authFont(.largeTitle) - .fontWeight(.bold) - .padding() - .accessibilityIdentifier("signed-in-text") - Text( - "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" - ) - .authCustomFont(.body) - if authService.currentUser?.isEmailVerified == false { - Button { - Task { - try await sendEmailVerification() - } - } label: { - Text(authService.string.sendEmailVerificationButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("verify-email-button") - } - Button { - authService.navigator.push(.updatePassword) - } label: { - Text(authService.string.updatePasswordButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("update-password-button") - - if authService.configuration.mfaEnabled { - Button { - authService.navigator.push(.mfaManagement) - } label: { - Text(authService.string.manageTwoFactorAuthenticationLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("mfa-management-button") - } - - Button { - showDeleteConfirmation = true - } label: { - Text(authService.string.deleteAccountButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("delete-account-button") - - Button { - Task { - do { - try await authService.signOut() - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - } label: { - Text(authService.string.signOutButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("sign-out-button") - } - .safeAreaPadding() - .withReauthentication(coordinator: reauthCoordinator) - .sheet(isPresented: $showDeleteConfirmation) { - DeleteAccountConfirmationSheet( - onConfirm: { - showDeleteConfirmation = false - Task { - do { - try await withReauthenticationIfNeeded( - authService: authService, - coordinator: reauthCoordinator - ) { - try await authService.deleteUser() - } - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }, - onCancel: { - showDeleteConfirmation = false - } - ) - .presentationDetents([.medium]) - } - .alert( - authService.string.verifyEmailSheetMessage, - isPresented: $showEmailVerificationSent - ) { - Button(authService.string.okButtonLabel) { - showEmailVerificationSent = false - } - } message: { - Text(authService.string.emailVerificationTapLinkMessage) - } - } -} - -private struct DeleteAccountConfirmationSheet: View { - @Environment(AuthService.self) private var authService - let onConfirm: () -> Void - let onCancel: () -> Void - - var body: some View { - VStack(spacing: 24) { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 60)) - .foregroundColor(.red) - - Text("Delete Account?") - .authFont(.title) - .fontWeight(.bold) - - Text( - "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - VStack(spacing: 12) { - Button { - onConfirm() - } label: { - Text("Delete Account") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.red) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("confirm-delete-button") - - Button { - onCancel() - } label: { - Text("Cancel") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("cancel-delete-button") - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() + SignedInScreen { DefaultSignedInContent(state: $0) } } }