diff --git a/AppUIComponents/Common/ThemeManager.swift b/AppUIComponents/Common/ThemeManager.swift index 8ebcb73..e658cff 100644 --- a/AppUIComponents/Common/ThemeManager.swift +++ b/AppUIComponents/Common/ThemeManager.swift @@ -5,13 +5,25 @@ import Combine @MainActor final class ThemeManager: ObservableObject { + // MARK: - Persistence + private static let persistenceKey = "auth0_selected_theme" + // MARK: - Published State - @Published private(set) var currentTheme: Auth0Theme = Auth0Theme() - @Published private(set) var activeOption: ThemeOption = .automatic + @Published private(set) var currentTheme: Auth0Theme + @Published private(set) var activeOption: ThemeOption + + // MARK: - Init + init() { + let saved = UserDefaults.standard.string(forKey: Self.persistenceKey) + .flatMap(ThemeOption.init(rawValue:)) ?? .automatic + activeOption = saved + currentTheme = saved.theme + } // MARK: - Apply func apply(_ option: ThemeOption) { activeOption = option currentTheme = option.theme + UserDefaults.standard.set(option.rawValue, forKey: Self.persistenceKey) } } diff --git a/AppUIComponents/LoginOptions/LoginOptionsView.swift b/AppUIComponents/LoginOptions/LoginOptionsView.swift index 1db609b..7995874 100644 --- a/AppUIComponents/LoginOptions/LoginOptionsView.swift +++ b/AppUIComponents/LoginOptions/LoginOptionsView.swift @@ -27,6 +27,8 @@ struct LoginOptionsView: View { VStack(spacing: 0) { Image("ic_auth0", bundle: .main) + .renderingMode(.template) + .foregroundStyle(theme.colors.text.bold) .aspectRatio(106/40, contentMode: .fit) .frame(width: 106) .padding(.top, 60) @@ -42,6 +44,8 @@ struct LoginOptionsView: View { } label: { HStack(spacing: 6) { Image("ic_appearance") + .renderingMode(.template) + .foregroundStyle(theme.colors.background.primary) Text("Appearance") .auth0TextStyle(theme.typography.title) @@ -109,7 +113,7 @@ struct LoginOptionsView: View { .padding(.horizontal, 36) .padding(.vertical, 28) .background(theme.colors.background.layerTop) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: theme.radius.large)) .shadow(color: Color.black.opacity(0.18), radius: 24, x: 0, y: 8) } } @@ -118,24 +122,24 @@ struct LoginOptionsView: View { private var backgroundView: some View { GeometryReader { geo in ZStack { - Color("F5F5F5", bundle: .main) - + theme.colors.background.layerBase + VStack(spacing: 0) { Spacer() - + LinearGradient( - stops: [ - Gradient.Stop(color: Color(red: 0.97, green: 0.97, blue: 0.96), location: 0.13), - Gradient.Stop(color: Color(red: 0.83, green: 0.82, blue: 1), location: 0.28), - Gradient.Stop(color: Color(red: 0.97, green: 0.73, blue: 0.47), location: 0.52), - Gradient.Stop(color: Color(red: 0.94, green: 0.61, blue: 0.02), location: 0.71), - Gradient.Stop(color: Color(red: 0.93, green: 0.92, blue: 0.91), location: 0.99), - ], - startPoint: UnitPoint(x: 0.24, y: 0.22), - endPoint: UnitPoint(x: 0.95, y: 0.24) + stops: [ + Gradient.Stop(color: theme.colors.background.layerBase, location: 0.00), + Gradient.Stop(color: theme.colors.background.accent.opacity(0.35), location: 0.25), + Gradient.Stop(color: theme.colors.background.primary.opacity(0.25), location: 0.52), + Gradient.Stop(color: theme.colors.background.accent.opacity(0.20), location: 0.75), + Gradient.Stop(color: theme.colors.background.layerBase, location: 1.00), + ], + startPoint: UnitPoint(x: 0.24, y: 0.22), + endPoint: UnitPoint(x: 0.95, y: 0.24) ) - .frame(height: geo.size.height/4) - .blur(radius: 155.91499) + .frame(height: geo.size.height / 4) + .blur(radius: 155) } } .ignoresSafeArea() @@ -148,6 +152,7 @@ struct LoginOptionsView: View { VStack(spacing: 16) { Text("Choose how to sign in") .auth0TextStyle(theme.typography.display) + .foregroundStyle(theme.colors.text.bold) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .padding(.bottom, theme.spacing.xs) diff --git a/AppUIComponents/Splash/SplashView.swift b/AppUIComponents/Splash/SplashView.swift index c79a126..501e34b 100644 --- a/AppUIComponents/Splash/SplashView.swift +++ b/AppUIComponents/Splash/SplashView.swift @@ -2,16 +2,24 @@ import SwiftUI import Auth0UniversalComponents struct SplashView: View { - + // MARK: - Router @EnvironmentObject var router: Router - + + // MARK: - Theme + @Environment(\.auth0Theme) private var theme + // MARK: - Main body var body: some View { GeometryReader { proxy in Image("ic_auth0", bundle: .main) + .renderingMode(.template) + .foregroundStyle(theme.colors.text.bold) .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY) - }.onAppear { + } + .background(theme.colors.background.layerBase) + .ignoresSafeArea() + .onAppear { withAnimation(.easeInOut.delay(2)) { navigateToLoginOptions() } diff --git a/AppUIComponents/Welcome/WelcomeView.swift b/AppUIComponents/Welcome/WelcomeView.swift index 62dbfd8..7d1dcb8 100644 --- a/AppUIComponents/Welcome/WelcomeView.swift +++ b/AppUIComponents/Welcome/WelcomeView.swift @@ -35,7 +35,7 @@ struct WelcomeView: View { #if !os(macOS) .navigationBarBackButtonHidden() #endif - .background(theme.colors.background.layerBase) + .background(theme.colors.background.layerBase.ignoresSafeArea()) } // MARK: - Header View @@ -81,6 +81,8 @@ struct WelcomeView: View { VStack(alignment: .leading, spacing: 0) { Image(item.icon.wrappedValue, bundle: .main) + .renderingMode(.template) + .foregroundStyle(theme.colors.background.primary) .padding(theme.spacing.xxs) .frame(width: theme.sizes.iconLarge, height: theme.sizes.iconLarge) @@ -101,9 +103,9 @@ struct WelcomeView: View { .frame(maxWidth: .infinity, minHeight: tileHeight > 0 ? tileHeight : nil) .contentShape(Rectangle()) .background(theme.colors.background.layerMedium) - .cornerRadius(20) + .cornerRadius(theme.radius.large) .overlay { - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: theme.radius.large) .inset(by: 0.5) .stroke(theme.colors.border.regular, lineWidth: 1) } @@ -129,8 +131,8 @@ struct WelcomeView: View { } } label: { Text("Log out") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Color("262420", bundle: .main)) + .auth0TextStyle(theme.typography.label) + .foregroundStyle(theme.colors.text.bold) .frame(maxWidth: .infinity, alignment: .center) .frame(height: theme.sizes.buttonHeight) } diff --git a/Auth0UniversalComponents/AuthMethodsHome/Presentation/EnrollPasskeyView.swift b/Auth0UniversalComponents/AuthMethodsHome/Presentation/EnrollPasskeyView.swift index 3917085..c335cf1 100644 --- a/Auth0UniversalComponents/AuthMethodsHome/Presentation/EnrollPasskeyView.swift +++ b/Auth0UniversalComponents/AuthMethodsHome/Presentation/EnrollPasskeyView.swift @@ -75,7 +75,7 @@ struct EnrollPasskeyView: View { endPoint: UnitPoint(x: 0.5, y: 1) ) ) - .background(Color.white) + .background(theme.colors.background.layerTop) .clipShape(RoundedRectangle(cornerRadius: theme.radius.button)) .overlay( RoundedRectangle(cornerRadius: theme.radius.button) @@ -101,7 +101,7 @@ struct EnrollPasskeyView: View { } .padding(.all, theme.spacing.lg) - .background(Color("Muted", bundle: ResourceBundle.default)) + .background(theme.colors.background.layerMedium) .clipShape(RoundedRectangle(cornerRadius: theme.radius.medium)) .fullScreenCoverOrSheet(isPresented: $viewModel.showLoader) { Auth0Loader() diff --git a/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodView.swift b/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodView.swift index da0e244..3132803 100644 --- a/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodView.swift +++ b/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodView.swift @@ -21,6 +21,8 @@ struct MyAccountAuthMethodView: View { var body: some View { HStack { Image(viewModel.image(), bundle: ResourceBundle.default) + .renderingMode(.template) + .foregroundStyle(theme.colors.background.primary) .frame(width: theme.sizes.iconMedium, height: theme.sizes.iconMedium) .padding(.trailing, theme.spacing.md) diff --git a/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodsView.swift b/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodsView.swift index 8b4d859..8d6eac4 100644 --- a/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodsView.swift +++ b/Auth0UniversalComponents/AuthMethodsHome/Presentation/MyAccountAuthMethodsView.swift @@ -80,9 +80,11 @@ public struct MyAccountAuthMethodsView: View { // Embedded: no inner NavigationStack — destinations attach to the // host stack. The SDK router is redirected to the host path on appear. sdkRootContent - .navigationTitle(Text("Login & Security")) + .themedNavigationTitle("Login & Security", theme: theme) #if !os(macOS) .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(theme.colors.background.layerBase, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) #endif #if !os(macOS) .navigationBarBackButtonHidden(true) @@ -109,9 +111,11 @@ public struct MyAccountAuthMethodsView: View { // Standalone: the SDK owns the full NavigationStack. NavigationStack(path: $router.path) { sdkRootContent - .navigationTitle(Text("Login & Security")) + .themedNavigationTitle("Login & Security", theme: theme) #if !os(macOS) .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(theme.colors.background.layerBase, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) #endif .navigationDestination(for: Route.self) { route in ViewFactory.view(for: route, delegate: viewModel) @@ -127,6 +131,8 @@ public struct MyAccountAuthMethodsView: View { @ViewBuilder private var sdkRootContent: some View { ZStack { + theme.colors.background.layerBase + .ignoresSafeArea() if let errorViewModel = viewModel.errorViewModel { ErrorScreen(viewModel: errorViewModel) } else { @@ -170,7 +176,7 @@ public struct MyAccountAuthMethodsView: View { .padding(.horizontal, 36) .padding(.vertical, 28) .background(theme.colors.background.layerTop) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: theme.radius.large)) .shadow(color: Color.black.opacity(0.18), radius: 24, x: 0, y: 8) } } diff --git a/Auth0UniversalComponents/Core/Theme/Auth0RadiusTokens.swift b/Auth0UniversalComponents/Core/Theme/Auth0RadiusTokens.swift index 6d013fa..fb8c7a0 100644 --- a/Auth0UniversalComponents/Core/Theme/Auth0RadiusTokens.swift +++ b/Auth0UniversalComponents/Core/Theme/Auth0RadiusTokens.swift @@ -29,6 +29,9 @@ public protocol Auth0RadiusTokens: Sendable { /// 16 pt — Primary CTA buttons and authenticator-method cards. var button: CGFloat { get } + /// 20 pt — Floating dialog / modal cards (loading overlays, alert cards). + var large: CGFloat { get } + /// 24 pt — Fully rounded pill-style outline buttons ("Copy as Code", "Copy Code"). var pill: CGFloat { get } } @@ -45,6 +48,7 @@ public struct DefaultAuth0RadiusTokens: Auth0RadiusTokens { public var medium: CGFloat public var inputField: CGFloat public var button: CGFloat + public var large: CGFloat public var pill: CGFloat /// Creates the default Auth0 corner-radius scale with optional per-token overrides. @@ -54,18 +58,21 @@ public struct DefaultAuth0RadiusTokens: Auth0RadiusTokens { /// - medium: Default `12` — used for banner cards. /// - inputField: Default `14` — used for text inputs and code containers. /// - button: Default `16` — used for CTA buttons and auth-method cards. + /// - large: Default `20` — used for floating dialog / modal cards. /// - pill: Default `24` — used for pill-shaped outline buttons. public init( small: CGFloat = 8, medium: CGFloat = 12, inputField: CGFloat = 14, button: CGFloat = 16, + large: CGFloat = 20, pill: CGFloat = 24 ) { self.small = small self.medium = medium self.inputField = inputField self.button = button + self.large = large self.pill = pill } } diff --git a/Auth0UniversalComponents/Core/Utils/Auth0Loader.swift b/Auth0UniversalComponents/Core/Utils/Auth0Loader.swift index c3ceb87..1a355b1 100644 --- a/Auth0UniversalComponents/Core/Utils/Auth0Loader.swift +++ b/Auth0UniversalComponents/Core/Utils/Auth0Loader.swift @@ -57,7 +57,7 @@ struct Auth0Loader: View { .padding(.horizontal, 36) .padding(.vertical, 28) .background(theme.colors.background.layerTop) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: theme.radius.large)) .shadow(color: Color.black.opacity(0.18), radius: 24, x: 0, y: 8) } } diff --git a/Auth0UniversalComponents/Core/Utils/Error/ErrorScreen.swift b/Auth0UniversalComponents/Core/Utils/Error/ErrorScreen.swift index ec58da3..3a08bec 100644 --- a/Auth0UniversalComponents/Core/Utils/Error/ErrorScreen.swift +++ b/Auth0UniversalComponents/Core/Utils/Error/ErrorScreen.swift @@ -62,6 +62,6 @@ struct ErrorScreen: View { .padding(theme.spacing.md) } } - .background(theme.colors.background.layerBase) + .background(theme.colors.background.layerBase.ignoresSafeArea()) } } diff --git a/Auth0UniversalComponents/Core/Utils/OTPTextField.swift b/Auth0UniversalComponents/Core/Utils/OTPTextField.swift index fb64fce..7e10a4b 100644 --- a/Auth0UniversalComponents/Core/Utils/OTPTextField.swift +++ b/Auth0UniversalComponents/Core/Utils/OTPTextField.swift @@ -27,6 +27,8 @@ struct OTPTextField: UIViewRepresentable { var index: Int /// Total number of digits in the OTP code var digitCount: Int + /// Cursor (caret) tint colour — should match the active theme's primary colour + var cursorTintColor: Color /// Callback when text is entered in this field var setText: ((String) -> Void) /// Callback when the enter/return key is pressed @@ -45,7 +47,7 @@ struct OTPTextField: UIViewRepresentable { textField.clearButtonMode = .never textField.autocorrectionType = .no textField.spellCheckingType = .no - textField.tintColor = .gray + textField.tintColor = UIColor(cursorTintColor) textField.keyboardType = .numberPad textField.textContentType = .oneTimeCode let toolbar = UIToolbar() @@ -64,6 +66,7 @@ struct OTPTextField: UIViewRepresentable { /// Updates the UITextField when the SwiftUI binding changes. func updateUIView(_ uiView: UITextField, context: Context) { uiView.text = self.getText() + uiView.tintColor = UIColor(cursorTintColor) self.setSelection(uiView) uiView.setContentHuggingPriority(.defaultHigh, for: .vertical) @@ -167,6 +170,8 @@ struct OTPTextField: NSViewRepresentable { var index: Int /// Total number of digits in the OTP code var digitCount: Int + /// Cursor tint colour — accepted for API parity with the iOS version; AppKit has no tintColor equivalent + var cursorTintColor: Color /// Callback when text is entered in this field var setText: ((String) -> Void) /// Callback when the enter/return key is pressed diff --git a/Auth0UniversalComponents/Core/Utils/View+Extension.swift b/Auth0UniversalComponents/Core/Utils/View+Extension.swift index 6050c74..7992a12 100644 --- a/Auth0UniversalComponents/Core/Utils/View+Extension.swift +++ b/Auth0UniversalComponents/Core/Utils/View+Extension.swift @@ -1,6 +1,32 @@ import SwiftUI extension View { + /// Applies a navigation title whose text colour adapts to the active Auth0 theme. + /// + /// SwiftUI's built-in `.navigationTitle` text colour is system-controlled and + /// cannot be overridden via foreground modifiers. This helper keeps + /// `.navigationTitle` so that child screens still show the correct back-button + /// label, and additionally injects a `ToolbarItem(placement: .principal)` to + /// render the title in `theme.colors.text.bold` using the theme typography. + /// + /// - Parameters: + /// - title: The navigation title string. + /// - theme: The active `Auth0Theme` to source the colour and typography from. + @ViewBuilder + func themedNavigationTitle(_ title: String, theme: Auth0Theme) -> some View { + self + .navigationTitle(title) + #if !os(macOS) + .toolbar { + ToolbarItem(placement: .principal) { + Text(title) + .auth0TextStyle(theme.typography.title) + .foregroundStyle(theme.colors.text.bold) + } + } + #endif + } + /// Attaches a toast notification modifier to the view. /// /// This convenience method applies the ToastModifier to display toast notifications diff --git a/Auth0UniversalComponents/EmailSMSEnrollment/Presentation/EmailPhoneEnrollmentView.swift b/Auth0UniversalComponents/EmailSMSEnrollment/Presentation/EmailPhoneEnrollmentView.swift index 522ce53..b50a59a 100644 --- a/Auth0UniversalComponents/EmailSMSEnrollment/Presentation/EmailPhoneEnrollmentView.swift +++ b/Auth0UniversalComponents/EmailSMSEnrollment/Presentation/EmailPhoneEnrollmentView.swift @@ -53,8 +53,11 @@ struct EmailPhoneEnrollmentView: View { }) Image("chevrondown", bundle: ResourceBundle.default) + .renderingMode(.template) + .foregroundStyle(theme.colors.text.bold) .frame(width: 10, height: 5.5) TextField("Phone Number", text: $viewModel.phoneNumber) + .foregroundStyle(theme.colors.text.bold) #if !os(macOS) .keyboardType(.numberPad) #endif @@ -62,6 +65,7 @@ struct EmailPhoneEnrollmentView: View { } .padding() .frame(height: theme.sizes.inputHeight) + .background(theme.colors.background.layerTop) .clipShape(RoundedRectangle(cornerRadius: theme.radius.inputField)) .overlay { RoundedRectangle(cornerRadius: theme.radius.inputField) @@ -79,9 +83,11 @@ struct EmailPhoneEnrollmentView: View { } } else { TextField("Email", text: $viewModel.email) + .foregroundStyle(theme.colors.text.bold) .focused($textFieldFocused) .padding() .frame(height: theme.sizes.inputHeight) + .background(theme.colors.background.layerTop) .clipShape(RoundedRectangle(cornerRadius: theme.radius.inputField)) .overlay { RoundedRectangle(cornerRadius: theme.radius.inputField) @@ -132,8 +138,14 @@ struct EmailPhoneEnrollmentView: View { Spacer() } .padding() + .background(theme.colors.background.layerBase.ignoresSafeArea()) .ignoresSafeArea(.keyboard) - .navigationTitle(Text(viewModel.navigationTitle)) + .themedNavigationTitle(viewModel.navigationTitle, theme: theme) + #if !os(macOS) + .toolbarBackground(theme.colors.background.layerBase, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .tint(theme.colors.text.bold) + #endif .toolbar { #if !os(visionOS) ToolbarItemGroup(placement: .keyboard) { diff --git a/Auth0UniversalComponents/RecoveryCode/Presentation/RecoveryCodeEnrollmentView.swift b/Auth0UniversalComponents/RecoveryCode/Presentation/RecoveryCodeEnrollmentView.swift index a3a15df..cada746 100644 --- a/Auth0UniversalComponents/RecoveryCode/Presentation/RecoveryCodeEnrollmentView.swift +++ b/Auth0UniversalComponents/RecoveryCode/Presentation/RecoveryCodeEnrollmentView.swift @@ -122,15 +122,19 @@ struct RecoveryCodeEnrollmentView: View { }.padding() } } + .background(theme.colors.background.layerBase.ignoresSafeArea()) .onAppear { Task { await viewModel.loadData() } } .toastView(toast: $viewModel.toast) - .navigationTitle(Text("Recovery code")) + .themedNavigationTitle("Recovery code", theme: theme) #if os(iOS) || os(watchOS) .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(theme.colors.background.layerBase, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .tint(theme.colors.text.bold) #endif .onChange(of: viewModel.navigationRoute) { _ in guard let route = viewModel.navigationRoute else { return } diff --git a/Auth0UniversalComponents/SaveAuthenticators/Presentation/SavedAuthenticatorsView.swift b/Auth0UniversalComponents/SaveAuthenticators/Presentation/SavedAuthenticatorsView.swift index 54d7dc9..66650fb 100644 --- a/Auth0UniversalComponents/SaveAuthenticators/Presentation/SavedAuthenticatorsView.swift +++ b/Auth0UniversalComponents/SaveAuthenticators/Presentation/SavedAuthenticatorsView.swift @@ -60,13 +60,18 @@ struct SavedAuthenticatorsView: View { trailing: theme.spacing.md)) } } - .navigationTitle(viewModel.type.savedAuthenticatorsNavigationTitle) + .themedNavigationTitle(viewModel.type.savedAuthenticatorsNavigationTitle, theme: theme) #if !os(macOS) .navigationBarBackButtonHidden(true) + .toolbarBackground(theme.colors.background.layerBase, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .tint(theme.colors.text.bold) #endif .toolbar { ToolbarItem(placement: trailingPlacement) { Image("plus", bundle: ResourceBundle.default) + .renderingMode(.template) + .foregroundStyle(theme.colors.text.bold) .onTapGesture { router.navigate(to: viewModel.type.navigationDestination([])) } @@ -74,6 +79,8 @@ struct SavedAuthenticatorsView: View { ToolbarItem(placement: leadingPlacement) { Image("back", bundle: ResourceBundle.default) + .renderingMode(.template) + .foregroundStyle(theme.colors.text.bold) .onTapGesture { router.pop() } @@ -84,7 +91,7 @@ struct SavedAuthenticatorsView: View { await viewModel.loadData() } } - .background(theme.colors.background.layerBase) + .background(theme.colors.background.layerBase.ignoresSafeArea()) } var trailingPlacement: ToolbarItemPlacement { diff --git a/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/OTPView.swift b/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/OTPView.swift index 9bdbcde..bf08614 100644 --- a/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/OTPView.swift +++ b/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/OTPView.swift @@ -110,8 +110,14 @@ struct OTPView: View { }.padding(EdgeInsets(top: 39, leading: 16, bottom: 40, trailing: 16)) } } + .background(theme.colors.background.layerBase.ignoresSafeArea()) .ignoresSafeArea(.keyboard) - .navigationTitle(viewModel.navigationTitle) + .themedNavigationTitle(viewModel.navigationTitle, theme: theme) + #if !os(macOS) + .toolbarBackground(theme.colors.background.layerBase, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .tint(theme.colors.text.bold) + #endif .onAppear { focusedField = 0 } @@ -128,6 +134,7 @@ struct OTPView: View { fullText: $viewModel.otpText, index: index, digitCount: 6, + cursorTintColor: theme.colors.background.primary, setText: { string in self.setTextAtIndex(string, at: index) }, @@ -141,6 +148,8 @@ struct OTPView: View { .frame(width: theme.sizes.size2xlDimen, height: theme.sizes.size3xlDimen, alignment: .center) .background( ZStack { + RoundedRectangle(cornerRadius: theme.radius.small, style: .continuous) + .fill(theme.colors.background.layerTop) if focusedField == index { RoundedRectangle(cornerRadius: theme.radius.small, style: .continuous) .strokeBorder(theme.colors.border.subtle, lineWidth: 3) diff --git a/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeView.swift b/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeView.swift index 230c50a..06583f5 100644 --- a/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeView.swift +++ b/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeView.swift @@ -21,18 +21,57 @@ struct TOTPPushQRCodeView: View { _viewModel = StateObject(wrappedValue: viewModel) } - /// Core Image context for QR code generation + /// Core Image context for QR code rendering private let context = CIContext() /// QR code filter for generating QR codes private let filter = CIFilter.qrCodeGenerator() + /// Generates a theme-coloured QR code image from a URI string. + /// + /// Uses `CIFalseColor` to replace the default black modules with + /// `theme.colors.text.bold` and the white background with + /// `theme.colors.background.layerTop`, so the QR code adapts automatically + /// whenever the active theme changes. + private func makeQRCodeImage(from uri: String) -> Image? { + // "H" = highest QR error-correction level. QR supports four levels (L/M/Q/H); H allows + // ~30% of the code's modules to be damaged or obscured and still scan correctly. + // This is important here because CIFalseColor replaces every pixel with theme-derived + // colours — on low-contrast themes the visual difference between foreground and background + // modules shrinks, making the code harder for a scanner to decode. The extra redundancy + // in level H compensates for that reduced contrast. + filter.correctionLevel = "H" + filter.message = Data(uri.utf8) + guard let rawOutput = filter.outputImage else { return nil } + + let colored: CIImage + if let falseColor = CIFilter(name: "CIFalseColor") { + #if canImport(UIKit) + let fgCIColor = CIColor(color: UIColor(theme.colors.text.bold)) + let bgCIColor = CIColor(color: UIColor(theme.colors.background.layerTop)) + #elseif canImport(AppKit) + let fgCIColor = CIColor(color: NSColor(theme.colors.text.bold)) ?? CIColor.black + let bgCIColor = CIColor(color: NSColor(theme.colors.background.layerTop)) ?? CIColor.white + #endif + falseColor.setValue(rawOutput, forKey: "inputImage") + falseColor.setValue(fgCIColor, forKey: "inputColor0") + falseColor.setValue(bgCIColor, forKey: "inputColor1") + colored = falseColor.outputImage ?? rawOutput + } else { + colored = rawOutput + } + + guard let cgImage = context.createCGImage(colored, from: rawOutput.extent) else { return nil } + return Image(decorative: cgImage, scale: 1.0) + } + var body: some View { ZStack { if let errorViewModel = viewModel.errorViewModel { ErrorScreen(viewModel: errorViewModel) } else { VStack { - if let qrCodeImage = viewModel.qrCodeImage { + Spacer(minLength: 0) + if let uri = viewModel.qrCodeURI, let qrCodeImage = makeQRCodeImage(from: uri) { qrCodeImage .resizable() .interpolation(.none) @@ -68,6 +107,8 @@ struct TOTPPushQRCodeView: View { } label: { HStack(alignment: .center, spacing: theme.spacing.xs) { Image("copy", bundle: ResourceBundle.default) + .renderingMode(.template) + .foregroundStyle(theme.colors.background.primary) .frame(width: theme.sizes.iconSmall, height: theme.sizes.iconSmall) Text("Copy as Code") @@ -123,7 +164,9 @@ struct TOTPPushQRCodeView: View { #endif } } + Spacer(minLength: 0) } + .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.all, theme.spacing.md) } @@ -131,10 +174,14 @@ struct TOTPPushQRCodeView: View { Auth0Loader() } } + .background(theme.colors.background.layerBase.ignoresSafeArea()) .toastView(toast: $viewModel.toast) - .navigationTitle(viewModel.navigationTitle()) + .themedNavigationTitle(viewModel.navigationTitle(), theme: theme) #if !os(macOS) .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(theme.colors.background.layerBase, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .tint(theme.colors.text.bold) #endif .onAppear { Task { diff --git a/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeViewModel.swift b/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeViewModel.swift index ea6f899..806cf74 100644 --- a/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeViewModel.swift +++ b/Auth0UniversalComponents/TOTPPushEnrollment/Presentation/TOTPPushQRCodeViewModel.swift @@ -26,7 +26,7 @@ final class TOTPPushQRCodeViewModel: ObservableObject, ErrorViewModelHandler { private var totpEnrollmentChallenge: TOTPEnrollmentChallenge? private weak var delegate: RefreshAuthDataProtocol? private let errorHandler = ErrorHandler() - @Published var qrCodeImage: Image? + @Published var qrCodeURI: String? @Published var showLoader: Bool = true @Published var manualInputCode: String? @Published var errorViewModel: ErrorScreenViewModel? @@ -125,19 +125,7 @@ final class TOTPPushQRCodeViewModel: ObservableObject, ErrorViewModelHandler { } private func setAuthQRCodeImage() { - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.correctionLevel = "H" - let qrCodeURI: String? = totpEnrollmentChallenge?.authenticatorQRCodeURI ?? pushEnrollmentChallenge?.authenticatorQRCodeURI - if let qrCodeURI { - filter.message = Data(qrCodeURI.utf8) - } - - if let outputImage = filter.outputImage { - if let cgImage = context.createCGImage(outputImage, from: outputImage.extent) { - qrCodeImage = Image(decorative: cgImage, scale: 1.0) - } - } + qrCodeURI = totpEnrollmentChallenge?.authenticatorQRCodeURI ?? pushEnrollmentChallenge?.authenticatorQRCodeURI } private func setAuthManualSetupCode() { diff --git a/Auth0UniversalComponentsTests/TOTPPushEnrollment/TOTPPushQRViewModelTests.swift b/Auth0UniversalComponentsTests/TOTPPushEnrollment/TOTPPushQRViewModelTests.swift index aa4e4d6..56714d7 100644 --- a/Auth0UniversalComponentsTests/TOTPPushEnrollment/TOTPPushQRViewModelTests.swift +++ b/Auth0UniversalComponentsTests/TOTPPushEnrollment/TOTPPushQRViewModelTests.swift @@ -81,7 +81,7 @@ struct TOTPPushQRCodeViewModelTests { ) await MainActor.run { #expect(viewModel.showLoader == true) - #expect(viewModel.qrCodeImage == nil) + #expect(viewModel.qrCodeURI == nil) #expect(viewModel.manualInputCode == nil) #expect(viewModel.errorViewModel == nil) #expect(viewModel.apiCallInProgress == false) @@ -141,7 +141,7 @@ struct TOTPPushQRCodeViewModelTests { return (response, pushEnrollmentChallengeData) } await viewModel.fetchEnrollmentChallenge() - #expect(viewModel.qrCodeImage != nil) + #expect(viewModel.qrCodeURI != nil) } }