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/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..9b724a838d --- /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) + } + .disabled(state.isLoading) + } + .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) { + HStack(spacing: 4) { + Text(authService.string.resendCodeLabel) + if state.resendCountdown > 0 { + Text(Duration.seconds(state.resendCountdown), format: .time(pattern: .minuteSecond)) + .monospacedDigit() + } + } + .authCustomFont(.body) + .frame(maxWidth: .infinity) + } + .disabled(state.isLoading || state.resendCountdown > 0) + .accessibilityIdentifier("resend-code-button") + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.large) + .padding(.horizontal) + } +} 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/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift new file mode 100644 index 0000000000..2b8535d3a5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -0,0 +1,135 @@ +// 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 + + /// 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:)``. 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. 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. +/// +/// 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 isWorking = 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 + )) + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } + } + + private var isValid: Bool { + !phoneNumber.isEmpty + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = 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..5b681ccc8e --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -0,0 +1,200 @@ +// 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 { + /// The code the user typed. + public let verificationCode: Binding + + /// 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: @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 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. 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 +/// 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 + )) + .onChange(of: verificationCode) { errorMessage = nil } + .task(id: resendAvailableAt) { + while !Task.isCancelled { + resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + guard resendCountdown > 0 else { return } + try? await Task.sleep(for: .seconds(1)) + } + } + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func verify() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + switch try await authService.signIn(credentials: credential) { + case let .mfaRequired(mfaInfo): + mfaHandler?(mfaInfo) + case .signedIn: + 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 } + isWorking = true + errorMessage = nil + Task { + 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) } } } 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) } } }