diff --git a/Sources/FaceLiveness/FaceLivenessTheme.swift b/Sources/FaceLiveness/FaceLivenessTheme.swift new file mode 100644 index 0000000..b71bd46 --- /dev/null +++ b/Sources/FaceLiveness/FaceLivenessTheme.swift @@ -0,0 +1,263 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import SwiftUI +import UIKit + +// MARK: - FaceLivenessTheme + +/// Configuration for customizing the visual appearance of the Face Liveness detection UI. +/// +/// Create a theme with default values and override only the properties you need: +/// ```swift +/// var theme = FaceLivenessTheme() +/// theme.colors.primary = .blue +/// theme.oval.strokeWidth = 4 +/// theme.components.showRecordingIndicator = false +/// ``` +public struct FaceLivenessTheme { + + /// Colors used across the liveness UI. + public var colors: Colors + + /// Configuration for the oval overlay during face detection. + public var oval: OvalStyle + + /// Configuration for the instruction text pill shown during the liveness check. + public var instruction: InstructionStyle + + /// Controls which UI components are visible during the liveness check. + public var components: ComponentVisibility + + /// When non-nil, forces the specified color scheme on all liveness views. + /// Set to `.dark` for a dark-themed UI. Default is `nil` (follows system). + public var preferredColorScheme: ColorScheme? + + /// Custom view to display during loading/connecting states. + /// When `nil`, the default loading spinner is shown. + /// + /// Example: + /// ```swift + /// theme.customLoadingView = AnyView(MyCustomLoadingView()) + /// ``` + public var customLoadingView: AnyView? + + /// When `true`, camera permission prompts use a compact system alert + /// over the loading view instead of the full-screen camera permission view. + /// Default is `false`. + public var usesCompactCameraPermissionPrompt: Bool + + /// Layout style for the detection overlay. Default is `.default` (VStack-based). + /// Use `.fullScreenOval()` for a full-screen layout with a static oval placeholder. + public var layout: LayoutStyle + + /// Creates a theme with all default values matching the standard liveness UI. + public init() { + self.colors = Colors() + self.oval = OvalStyle() + self.instruction = InstructionStyle() + self.components = ComponentVisibility() + self.preferredColorScheme = nil + self.customLoadingView = nil + self.usesCompactCameraPermissionPrompt = false + self.layout = .default + } + + /// The default theme matching the standard liveness UI. + public static let `default` = FaceLivenessTheme() +} + +// MARK: - Colors + +extension FaceLivenessTheme { + /// Color configuration for the liveness UI. + public struct Colors { + /// Color for primary action elements + /// (buttons, active instruction pills, progress bars). + public var primary: Color + + /// Text/icon color used on primary elements. + public var onPrimary: Color + + /// General background color (verifying state). + public var background: Color + + /// Text/icon color on background. + public var onBackground: Color + + /// Component background color (close button, recording indicator). + public var surface: Color + + /// Text/icon color on surface components. + public var onSurface: Color + + /// Color for error instruction states (e.g., "Move face back"). + public var error: Color + + /// Text color for error instruction states. + public var onError: Color + + /// Background color for the photosensitivity warning box. + public var errorContainer: Color + + /// Text color for the photosensitivity warning box. + public var onErrorContainer: Color + + /// Stroke color for the preview ellipse on the Get Ready page. + public var previewBorder: Color + + /// Creates default colors matching the standard liveness UI. + public init() { + self.primary = .livenessPrimaryBackground + self.onPrimary = .livenessPrimaryLabel + self.background = .livenessBackground + self.onBackground = .livenessLabel + self.surface = .livenessBackground + self.onSurface = .livenessLabel + self.error = .livenessErrorBackground + self.onError = .livenessErrorLabel + self.errorContainer = .livenessWarningBackground + self.onErrorContainer = .livenessWarningLabel + self.previewBorder = .livenessPreviewBorder + } + } +} + +// MARK: - OvalStyle + +extension FaceLivenessTheme { + /// Configuration for the oval overlay that frames the user's face. + public struct OvalStyle { + /// Fill color for the mask area outside the oval. + /// Default: white at 90% opacity. + public var maskColor: UIColor + + /// Stroke color for the oval border. Default: white. + public var strokeColor: UIColor + + /// Line width for the oval border stroke. Default: 8. + public var strokeWidth: CGFloat + + /// Creates default oval style matching the standard liveness UI. + public init() { + self.maskColor = UIColor.white.withAlphaComponent(0.9) + self.strokeColor = .white + self.strokeWidth = 8 + } + } +} + +// MARK: - InstructionStyle + +extension FaceLivenessTheme { + /// Configuration for the instruction text shown during the liveness check. + public struct InstructionStyle { + /// Override for instruction background color. + /// When non-nil, all instruction states use this single color. + /// When nil (default), per-state colors from ``Colors`` are used. + public var backgroundColor: Color? + + /// Override for instruction text color. + /// When non-nil, all instruction states use this single color. + /// When nil (default), per-state colors from ``Colors`` are used. + public var textColor: Color? + + /// Font for the instruction text. Default: `.title`. + public var font: Font + + /// When `true`, uses a capsule shape for the instruction background. + /// When `false` (default), uses a rounded rectangle with ``cornerRadius``. + public var useCapsuleShape: Bool + + /// Corner radius for the instruction background. + /// Ignored when ``useCapsuleShape`` is `true`. Default: 8. + public var cornerRadius: CGFloat + + /// Padding around the instruction text. + /// Default: 12 on all edges. + public var padding: EdgeInsets + + /// Creates default instruction style matching the standard liveness UI. + public init() { + self.backgroundColor = nil + self.textColor = nil + self.font = .title + self.useCapsuleShape = false + self.cornerRadius = 8 + self.padding = EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + } + } +} + +// MARK: - ComponentVisibility + +extension FaceLivenessTheme { + /// Controls which UI components are visible during the liveness check. + public struct ComponentVisibility { + /// Whether to show the recording indicator (red dot + "REC" label). + /// Default: `true`. + public var showRecordingIndicator: Bool + + /// Whether to show the close button during the liveness check. + /// Default: `true`. + public var showCloseButton: Bool + + /// Whether to show the progress bar during face matching. + /// Default: `true`. + public var showProgressBar: Bool + + /// Creates default visibility settings (all components visible). + public init() { + self.showRecordingIndicator = true + self.showCloseButton = true + self.showProgressBar = true + } + } +} + +// MARK: - LayoutStyle + +extension FaceLivenessTheme { + /// Controls the layout of the detection overlay during the liveness check. + public enum LayoutStyle { + /// Default layout: VStack with top bar (recording indicator + close button), + /// instruction below the top bar, 3:4 aspect ratio content area. + case `default` + + /// Full-screen layout with a static oval placeholder shown before the SDK + /// draws its dynamic oval. The instruction pill is positioned above the oval. + /// Ideal for seamless transitions from a custom loading view that also + /// shows an oval. + /// + /// - Parameters: + /// - ovalWidth: Width of the static oval placeholder. Default: 250. + /// - ovalHeight: Height of the static oval placeholder. Default: 344. + /// - ovalYRatio: Vertical position of the oval center as a fraction of + /// screen height (0 = top, 1 = bottom). Default: 0.42. + /// - instructionOffset: Distance in points between the instruction pill's + /// bottom edge and the oval's top edge. Default: 30. + case fullScreenOval( + ovalWidth: CGFloat = 250, + ovalHeight: CGFloat = 344, + ovalYRatio: CGFloat = 0.42, + instructionOffset: CGFloat = 30 + ) + } +} + +// MARK: - SwiftUI Environment + +struct LivenessThemeKey: EnvironmentKey { + static let defaultValue = FaceLivenessTheme.default +} + +extension EnvironmentValues { + var livenessTheme: FaceLivenessTheme { + get { self[LivenessThemeKey.self] } + set { self[LivenessThemeKey.self] = newValue } + } +} diff --git a/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift b/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift index ee9f396..8b789c7 100644 --- a/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift +++ b/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift @@ -9,6 +9,7 @@ import SwiftUI struct CameraPermissionView: View { @Binding var displayingCameraPermissionsNeededAlert: Bool + @Environment(\.livenessTheme) var theme init( displayingCameraPermissionsNeededAlert: Binding = .constant(false) @@ -60,12 +61,12 @@ struct CameraPermissionView: View { action: goToSettingsAppPage, label: { Text(LocalizedStrings.camera_permission_change_setting_button_title) - .foregroundColor(.livenessPrimaryLabel) + .foregroundColor(theme.colors.onPrimary) .frame(maxWidth: .infinity) } ) .frame(height: 52) - ._background { Color.livenessPrimaryBackground } + ._background { theme.colors.primary } .cornerRadius(14) .padding([.leading, .trailing]) .padding(.bottom, 16) diff --git a/Sources/FaceLiveness/Views/CloseButton.swift b/Sources/FaceLiveness/Views/CloseButton.swift index dee45c4..9b21bc9 100644 --- a/Sources/FaceLiveness/Views/CloseButton.swift +++ b/Sources/FaceLiveness/Views/CloseButton.swift @@ -9,6 +9,7 @@ import SwiftUI struct CloseButton: View { let action: () -> Void + @Environment(\.livenessTheme) var theme var body: some View { Button( @@ -16,9 +17,9 @@ struct CloseButton: View { label: { Image(systemName: "xmark") .font(.system(size: 18, weight: .bold)) - .foregroundColor(.livenessLabel) + .foregroundColor(theme.colors.onSurface) .frame(width: 44, height: 44) - .background(Color.livenessBackground) + .background(theme.colors.surface) .clipShape(Circle()) .accessibilityLabel(Text(LocalizedStrings.close_button_a11y)) } diff --git a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift index 19e2f48..201986d 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift @@ -14,6 +14,7 @@ struct CameraPreviewView: View { private static let previewYPositionRatio = 0.6 @StateObject var model: CameraPreviewViewModel + @Environment(\.livenessTheme) var theme init(model: CameraPreviewViewModel = CameraPreviewViewModel(cameraPosition: .front)) { self._model = StateObject(wrappedValue: model) @@ -33,7 +34,7 @@ struct CameraPreviewView: View { }) GeometryReader { geometry in Ellipse() - .stroke(Color.livenessPreviewBorder, style: StrokeStyle(lineWidth: 3)) + .stroke(theme.colors.previewBorder, style: StrokeStyle(lineWidth: 3)) .frame(width: geometry.size.width*Self.previewWidthRatio, height: geometry.size.height*Self.previewHeightRatio) .position(x: geometry.size.width*Self.previewXPositionRatio, diff --git a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift index 5f5279e..0f9d0ab 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift @@ -13,7 +13,8 @@ struct GetReadyPageView: View { let onBegin: () -> Void let challenge: Challenge let cameraPosition: LivenessCamera - + @Environment(\.livenessTheme) var theme + init( onBegin: @escaping () -> Void, beginCheckButtonDisabled: Bool = false, @@ -53,13 +54,13 @@ struct GetReadyPageView: View { action: onBegin, label: { Text(LocalizedStrings.get_ready_begin_check) - .foregroundColor(.livenessPrimaryLabel) + .foregroundColor(theme.colors.onPrimary) .frame(maxWidth: .infinity) } ) .disabled(beginCheckButtonDisabled) .frame(height: 52) - ._background { Color.livenessPrimaryBackground } + ._background { theme.colors.primary } .cornerRadius(14) .padding([.leading, .trailing]) .padding(.bottom, 16) diff --git a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift index 0a4e036..dd320b0 100644 --- a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift +++ b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift @@ -11,15 +11,15 @@ import Combine struct InstructionContainerView: View { @ObservedObject var viewModel: FaceLivenessDetectionViewModel + @Environment(\.livenessTheme) var theme var body: some View { switch viewModel.livenessState.state { case .displayingFreshness: - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_hold_still, - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) .onAppear { UIAccessibility.post( @@ -29,11 +29,10 @@ struct InstructionContainerView: View { } case .awaitingFaceInOvalMatch(.faceTooClose, _): - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_move_face_back, - backgroundColor: .livenessErrorBackground, - textColor: .livenessErrorLabel, - font: .title + defaultBackgroundColor: theme.colors.error, + defaultTextColor: theme.colors.onError ) .onAppear { UIAccessibility.post( @@ -43,27 +42,27 @@ struct InstructionContainerView: View { } case .awaitingFaceInOvalMatch(let reason, let percentage): - InstructionView( + themedInstructionView( text: .init(reason.localizedValue), - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) - ProgressBarView( - emptyColor: .white, - borderColor: .hex("#AEB3B7"), - fillColor: .livenessPrimaryBackground, - indicatorColor: .livenessPrimaryBackground, - percentage: percentage - ) - .frame(width: 200, height: 30) + if theme.components.showProgressBar { + ProgressBarView( + emptyColor: .white, + borderColor: .hex("#AEB3B7"), + fillColor: theme.colors.primary, + indicatorColor: theme.colors.primary, + percentage: percentage + ) + .frame(width: 200, height: 30) + } case .recording(ovalDisplayed: true): - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_move_face_closer, - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) .onAppear { UIAccessibility.post( @@ -72,25 +71,27 @@ struct InstructionContainerView: View { ) } - ProgressBarView( - emptyColor: .white, - borderColor: .hex("#AEB3B7"), - fillColor: .livenessPrimaryBackground, - indicatorColor: .livenessPrimaryBackground, - percentage: 0.2 - ) - .frame(width: 200, height: 30) + if theme.components.showProgressBar { + ProgressBarView( + emptyColor: .white, + borderColor: .hex("#AEB3B7"), + fillColor: theme.colors.primary, + indicatorColor: theme.colors.primary, + percentage: 0.2 + ) + .frame(width: 200, height: 30) + } case .pendingFacePreparedConfirmation(let reason): - InstructionView( + themedInstructionView( text: .init(reason.localizedValue), - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) case .completedDisplayingFreshness: - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_verifying, - backgroundColor: .livenessBackground + defaultBackgroundColor: theme.colors.background, + defaultTextColor: theme.colors.onBackground ) .onAppear { UIAccessibility.post( @@ -99,9 +100,10 @@ struct InstructionContainerView: View { ) } case .completedNoLightCheck: - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_verifying, - backgroundColor: .livenessBackground + defaultBackgroundColor: theme.colors.background, + defaultTextColor: theme.colors.onBackground ) .onAppear { UIAccessibility.post( @@ -112,11 +114,10 @@ struct InstructionContainerView: View { case .faceMatched: if let challenge = viewModel.challengeReceived, case .faceMovementAndLightChallenge = challenge { - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_hold_still, - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) } else { EmptyView() @@ -125,4 +126,22 @@ struct InstructionContainerView: View { EmptyView() } } + + /// Creates an ``InstructionView`` using theme overrides when set, + /// falling back to the provided per-state default colors. + private func themedInstructionView( + text: String, + defaultBackgroundColor: Color, + defaultTextColor: Color + ) -> InstructionView { + InstructionView( + text: text, + backgroundColor: theme.instruction.backgroundColor ?? defaultBackgroundColor, + textColor: theme.instruction.textColor ?? defaultTextColor, + font: theme.instruction.font, + useCapsuleShape: theme.instruction.useCapsuleShape, + cornerRadius: theme.instruction.cornerRadius, + padding: theme.instruction.padding + ) + } } diff --git a/Sources/FaceLiveness/Views/Instruction/InstructionView.swift b/Sources/FaceLiveness/Views/Instruction/InstructionView.swift index 5311387..098de9e 100644 --- a/Sources/FaceLiveness/Views/Instruction/InstructionView.swift +++ b/Sources/FaceLiveness/Views/Instruction/InstructionView.swift @@ -11,14 +11,27 @@ struct InstructionView: View { let text: String let backgroundColor: Color var textColor: Color = .livenessLabel - var font: Font = .body - + var font: Font = .title + var useCapsuleShape: Bool = false + var cornerRadius: CGFloat = 8 + var padding: EdgeInsets = EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + var body: some View { Text(text) .foregroundColor(textColor) .font(font) - .padding(12) - .background(backgroundColor) - .cornerRadius(8) + .multilineTextAlignment(.center) + .padding(padding) + .background(backgroundShape) + } + + @ViewBuilder + private var backgroundShape: some View { + if useCapsuleShape { + Capsule().fill(backgroundColor) + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(backgroundColor) + } } } diff --git a/Sources/FaceLiveness/Views/Liveness/CameraView.swift b/Sources/FaceLiveness/Views/Liveness/CameraView.swift index e984bce..c8f435c 100644 --- a/Sources/FaceLiveness/Views/Liveness/CameraView.swift +++ b/Sources/FaceLiveness/Views/Liveness/CameraView.swift @@ -11,18 +11,22 @@ import AWSPredictionsPlugin struct CameraView: UIViewControllerRepresentable { @ObservedObject var faceLivenessDetectionViewModel: FaceLivenessDetectionViewModel + let ovalStyle: FaceLivenessTheme.OvalStyle init( - faceLivenessDetectionViewModel: FaceLivenessDetectionViewModel + faceLivenessDetectionViewModel: FaceLivenessDetectionViewModel, + ovalStyle: FaceLivenessTheme.OvalStyle = .init() ) { self.faceLivenessDetectionViewModel = faceLivenessDetectionViewModel + self.ovalStyle = ovalStyle } func makeUIViewController( context: Context ) -> _LivenessViewController { let livenessViewController = _LivenessViewController( - viewModel: faceLivenessDetectionViewModel + viewModel: faceLivenessDetectionViewModel, + ovalStyle: ovalStyle ) return livenessViewController } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift index bad7e60..ac2076f 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift @@ -21,16 +21,30 @@ public struct FaceLivenessDetectorView: View { let disableStartView: Bool let challengeOptions: ChallengeOptions + let theme: FaceLivenessTheme let onCompletion: (Result) -> Void let sessionTask: Task + /// Creates a new Face Liveness detection view. + /// + /// - Parameters: + /// - sessionID: The liveness session ID from Amazon Rekognition. + /// - credentialsProvider: Optional custom AWS credentials provider. + /// - region: The AWS region (e.g., "us-east-1"). + /// - disableStartView: When `true`, skips the Get Ready page. Default: `false`. + /// - challengeOptions: Camera and challenge configuration. Default: front camera. + /// - theme: Visual customization for colors, oval, instructions, and component visibility. + /// Default: `.default` (standard liveness UI). + /// - isPresented: Binding to control view dismissal. + /// - onCompletion: Callback with the liveness detection result. public init( sessionID: String, credentialsProvider: AWSCredentialsProvider? = nil, region: String, disableStartView: Bool = false, challengeOptions: ChallengeOptions = .init(), + theme: FaceLivenessTheme = .default, isPresented: Binding, onCompletion: @escaping (Result) -> Void ) { @@ -38,6 +52,7 @@ public struct FaceLivenessDetectorView: View { self._isPresented = isPresented self.onCompletion = onCompletion self.challengeOptions = challengeOptions + self.theme = theme self.sessionTask = Task { let session = try await AWSPredictionsPlugin.startFaceLivenessSession( @@ -79,6 +94,7 @@ public struct FaceLivenessDetectorView: View { region: String, disableStartView: Bool = false, challengeOptions: ChallengeOptions = .init(), + theme: FaceLivenessTheme = .default, isPresented: Binding, onCompletion: @escaping (Result) -> Void, captureSession: LivenessCaptureSession @@ -87,6 +103,7 @@ public struct FaceLivenessDetectorView: View { self._isPresented = isPresented self.onCompletion = onCompletion self.challengeOptions = challengeOptions + self.theme = theme self.sessionTask = Task { let session = try await AWSPredictionsPlugin.startFaceLivenessSession( @@ -116,131 +133,176 @@ public struct FaceLivenessDetectorView: View { } public var body: some View { - switch displayState { - case .awaitingChallengeType: - LoadingPageView() - .onAppear { - Task { - do { - let session = try await sessionTask.value - viewModel.livenessService = session - viewModel.registerServiceEvents(onChallengeTypeReceived: { challenge in - self.displayState = DisplayState.awaitingCameraPermission(challenge) - }) - viewModel.initializeLivenessStream() - } catch let error as FaceLivenessDetectionError { - switch error { - case .unknown: - viewModel.livenessState.unrecoverableStateEncountered(.unknown) - case .sessionTimedOut, - .faceInOvalMatchExceededTimeLimitError, - .countdownFaceTooClose, - .countdownMultipleFaces, - .countdownNoFace: - viewModel.livenessState.unrecoverableStateEncountered(.timedOut) - case .cameraPermissionDenied: - viewModel.livenessState.unrecoverableStateEncountered(.missingVideoPermission) - case .userCancelled: - viewModel.livenessState.unrecoverableStateEncountered(.userCancelled) - case .socketClosed: - viewModel.livenessState.unrecoverableStateEncountered(.socketClosed) - case .cameraNotAvailable: - viewModel.livenessState.unrecoverableStateEncountered(.cameraNotAvailable) - default: + Group { + switch displayState { + case .awaitingChallengeType: + loadingView + .onAppear { + Task { + do { + let session = try await sessionTask.value + viewModel.livenessService = session + viewModel.registerServiceEvents(onChallengeTypeReceived: { challenge in + self.displayState = DisplayState.awaitingCameraPermission(challenge) + }) + viewModel.initializeLivenessStream() + } catch let error as FaceLivenessDetectionError { + switch error { + case .unknown: + viewModel.livenessState.unrecoverableStateEncountered(.unknown) + case .sessionTimedOut, + .faceInOvalMatchExceededTimeLimitError, + .countdownFaceTooClose, + .countdownMultipleFaces, + .countdownNoFace: + viewModel.livenessState.unrecoverableStateEncountered(.timedOut) + case .cameraPermissionDenied: + viewModel.livenessState.unrecoverableStateEncountered(.missingVideoPermission) + case .userCancelled: + viewModel.livenessState.unrecoverableStateEncountered(.userCancelled) + case .socketClosed: + viewModel.livenessState.unrecoverableStateEncountered(.socketClosed) + case .cameraNotAvailable: + viewModel.livenessState.unrecoverableStateEncountered(.cameraNotAvailable) + default: + viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) + } + } catch { viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) } - } catch { - viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) - } - - DispatchQueue.main.async { - if let faceDetector = viewModel.faceDetector as? FaceDetectorShortRange.Model { - faceDetector.setFaceDetectionSessionConfigurationWrapper(configuration: viewModel) + + DispatchQueue.main.async { + if let faceDetector = viewModel.faceDetector as? FaceDetectorShortRange.Model { + faceDetector.setFaceDetectionSessionConfigurationWrapper(configuration: viewModel) + } } } } - } - .onReceive(viewModel.$livenessState) { output in - switch output.state { - case .encounteredUnrecoverableError(let error): - let closeCode = error.webSocketCloseCode ?? .normalClosure - viewModel.livenessService?.closeSocket(with: closeCode) - isPresented = false - onCompletion(.failure(mapError(error))) - default: - break + .onReceive(viewModel.$livenessState) { output in + switch output.state { + case .encounteredUnrecoverableError(let error): + let closeCode = error.webSocketCloseCode ?? .normalClosure + viewModel.livenessService?.closeSocket(with: closeCode) + isPresented = false + onCompletion(.failure(mapError(error))) + default: + break + } } - } - case .awaitingCameraPermission(let challenge): - CameraPermissionView(displayingCameraPermissionsNeededAlert: $displayingCameraPermissionsNeededAlert) + case .awaitingCameraPermission(let challenge): + cameraPermissionContent + .onAppear { + checkCameraPermission(for: challenge) + } + case .awaitingLivenessSession(let challenge): + Color.clear + .onAppear { + Task { + let cameraPosition: LivenessCamera + switch challenge { + case .faceMovementAndLightChallenge: + cameraPosition = challengeOptions.faceMovementAndLightChallengeOption.camera + case .faceMovementChallenge: + cameraPosition = challengeOptions.faceMovementChallengeOption.camera + } + + let newState = disableStartView + ? DisplayState.displayingLiveness + : DisplayState.displayingGetReadyView(challenge, cameraPosition) + guard self.displayState != newState else { return } + self.displayState = newState + } + } + case .displayingGetReadyView(let challenge, let cameraPosition): + GetReadyPageView( + onBegin: { + guard displayState != .displayingLiveness else { return } + displayState = .displayingLiveness + }, + beginCheckButtonDisabled: false, + challenge: challenge, + cameraPosition: cameraPosition + ) .onAppear { - checkCameraPermission(for: challenge) + DispatchQueue.main.async { + UIScreen.main.brightness = 1.0 + } } - case .awaitingLivenessSession(let challenge): - Color.clear + case .displayingLiveness: + _FaceLivenessDetectionView( + viewModel: viewModel, + videoView: { + CameraView( + faceLivenessDetectionViewModel: viewModel, + ovalStyle: theme.oval + ) + } + ) .onAppear { - Task { - let cameraPosition: LivenessCamera - switch challenge { - case .faceMovementAndLightChallenge: - cameraPosition = challengeOptions.faceMovementAndLightChallengeOption.camera - case .faceMovementChallenge: - cameraPosition = challengeOptions.faceMovementChallengeOption.camera - } - - let newState = disableStartView - ? DisplayState.displayingLiveness - : DisplayState.displayingGetReadyView(challenge, cameraPosition) - guard self.displayState != newState else { return } - self.displayState = newState + DispatchQueue.main.async { + UIScreen.main.brightness = 1.0 } } - case .displayingGetReadyView(let challenge, let cameraPosition): - GetReadyPageView( - onBegin: { - guard displayState != .displayingLiveness else { return } - displayState = .displayingLiveness - }, - beginCheckButtonDisabled: false, - challenge: challenge, - cameraPosition: cameraPosition - ) - .onAppear { - DispatchQueue.main.async { - UIScreen.main.brightness = 1.0 + .onDisappear() { + viewModel.stopRecording() + } + .onReceive(viewModel.$livenessState) { output in + switch output.state { + case .completed: + isPresented = false + onCompletion(.success(())) + case .encounteredUnrecoverableError(let error): + let closeCode = error.webSocketCloseCode ?? .normalClosure + viewModel.livenessService?.closeSocket(with: closeCode) + isPresented = false + onCompletion(.failure(mapError(error))) + default: + break + } } } - case .displayingLiveness: - _FaceLivenessDetectionView( - viewModel: viewModel, - videoView: { - CameraView( - faceLivenessDetectionViewModel: viewModel + } + .environment(\.livenessTheme, theme) + .preferredColorScheme(theme.preferredColorScheme) + } + + // MARK: - Loading View + + @ViewBuilder + private var loadingView: some View { + if let customView = theme.customLoadingView { + customView + } else { + LoadingPageView() + } + } + + // MARK: - Camera Permission + + @ViewBuilder + private var cameraPermissionContent: some View { + if theme.usesCompactCameraPermissionPrompt { + loadingView + .alert(isPresented: $displayingCameraPermissionsNeededAlert) { + Alert( + title: Text(LocalizedStrings.camera_setting_alert_title), + message: Text(LocalizedStrings.camera_setting_alert_message), + primaryButton: .default( + Text(LocalizedStrings.camera_setting_alert_update_setting_button_text).bold(), + action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }), + secondaryButton: .default( + Text(LocalizedStrings.camera_setting_alert_not_now_button_text) + ) ) } + } else { + CameraPermissionView( + displayingCameraPermissionsNeededAlert: $displayingCameraPermissionsNeededAlert ) - .onAppear { - DispatchQueue.main.async { - UIScreen.main.brightness = 1.0 - } - } - .onDisappear() { - viewModel.stopRecording() - } - .onReceive(viewModel.$livenessState) { output in - switch output.state { - case .completed: - isPresented = false - onCompletion(.success(())) - case .encounteredUnrecoverableError(let error): - let closeCode = error.webSocketCloseCode ?? .normalClosure - viewModel.livenessService?.closeSocket(with: closeCode) - isPresented = false - onCompletion(.failure(mapError(error))) - default: - break - } - } } } diff --git a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift index 96c3114..9ce8bed 100644 --- a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift +++ b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift @@ -13,6 +13,7 @@ import Amplify final class _LivenessViewController: UIViewController { let viewModel: FaceLivenessDetectionViewModel + let ovalStyle: FaceLivenessTheme.OvalStyle var previewLayer: CALayer? let faceShapeLayer = CAShapeLayer() @@ -23,9 +24,11 @@ final class _LivenessViewController: UIViewController { var readyForOval = false init( - viewModel: FaceLivenessDetectionViewModel + viewModel: FaceLivenessDetectionViewModel, + ovalStyle: FaceLivenessTheme.OvalStyle = .init() ) { self.viewModel = viewModel + self.ovalStyle = ovalStyle super.init(nibName: nil, bundle: nil) viewModel.livenessViewControllerDelegate = self viewModel.normalizeFace = { [weak self] face in @@ -129,7 +132,15 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { } func displayFreshness(colorSequences: [FaceLivenessSession.DisplayColor]) { - self.ovalView?.setNeedsDisplay() + // Force white backgrounds during the freshness color check. + // The AWS backend validates the visible overlay colors, which are + // semi-transparent (alpha 0.75–0.9). If the background behind them + // is anything other than white, the blended color will differ and + // the liveness check may fail. + view.backgroundColor = .white + ovalView?.forceWhiteFill = true + ovalView?.setNeedsDisplay() + DispatchQueue.main.async { [weak self] in self?.viewModel.livenessState.displayingFreshness() } @@ -145,6 +156,11 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { guard let self else { return } self.freshnessView.removeFromSuperview() + // Revert to normal background after freshness completes + self.view.backgroundColor = .black + self.ovalView?.forceWhiteFill = false + self.ovalView?.setNeedsDisplay() + self.viewModel.handleFreshnessComplete() } ) @@ -157,7 +173,10 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { let ovalView = OvalView( frame: previewLayer.frame, - ovalFrame: ovalRect + ovalFrame: ovalRect, + maskColor: self.ovalStyle.maskColor, + strokeColor: self.ovalStyle.strokeColor, + strokeWidth: self.ovalStyle.strokeWidth ) self.ovalView = ovalView ovalView.center = previewLayer.position diff --git a/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift index 5113bf5..e68e428 100644 --- a/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift @@ -11,6 +11,7 @@ struct _FaceLivenessDetectionView: View { let videoView: VideoView @ObservedObject var viewModel: FaceLivenessDetectionViewModel @Binding var displayResultsView: Bool + @Environment(\.livenessTheme) var theme init( viewModel: FaceLivenessDetectionViewModel, @@ -25,37 +26,142 @@ struct _FaceLivenessDetectionView: View { ) } + /// During the freshness color check the AWS backend validates overlay + /// colors that are semi-transparent. A white background is required + /// so the blended colors match the expected values. + private var isFreshnessActive: Bool { + switch viewModel.livenessState.state { + case .displayingFreshness, .faceMatched: + return true + default: + return false + } + } + + /// Whether the SDK has drawn its dynamic oval (hide the static placeholder). + private var shouldShowStaticOval: Bool { + switch viewModel.livenessState.state { + case .recording(ovalDisplayed: true), + .awaitingFaceInOvalMatch(_, _), + .faceMatched, + .displayingFreshness, + .completedDisplayingFreshness, + .completedNoLightCheck: + return false + default: + return true + } + } + var body: some View { ZStack { - Color.black - ZStack { - videoView - VStack { - HStack(alignment: .top) { - if viewModel.livenessState.shouldDisplayRecordingIcon { - RecordingButton() - .accessibilityHidden(true) - } - - Spacer() + (isFreshnessActive ? Color.white : Color.black) + switch theme.layout { + case .default: + defaultLayout + case .fullScreenOval(let ovalWidth, let ovalHeight, let ovalYRatio, let instructionOffset): + fullScreenOvalLayout( + ovalWidth: ovalWidth, + ovalHeight: ovalHeight, + ovalYRatio: ovalYRatio, + instructionOffset: instructionOffset + ) + } + } + .edgesIgnoringSafeArea(.all) + } + + // MARK: - Default Layout (VStack, top bar, 3:4 aspect ratio) + + private var defaultLayout: some View { + ZStack { + videoView + VStack { + HStack(alignment: .top) { + if theme.components.showRecordingIndicator, + viewModel.livenessState.shouldDisplayRecordingIcon { + RecordingButton() + .accessibilityHidden(true) + } + Spacer() + + if theme.components.showCloseButton { CloseButton( action: viewModel.closeButtonAction ) } - .padding() + } + .padding() - InstructionContainerView( - viewModel: viewModel - ) + InstructionContainerView( + viewModel: viewModel + ) - Spacer() + Spacer() + } + .padding([.leading, .trailing]) + .aspectRatio(3/4, contentMode: .fit) + .frame(maxWidth: .infinity) + } + } + + // MARK: - Full-Screen Oval Layout (GeometryReader, static oval, instruction above) + + private func fullScreenOvalLayout( + ovalWidth: CGFloat, + ovalHeight: CGFloat, + ovalYRatio: CGFloat, + instructionOffset: CGFloat + ) -> some View { + ZStack { + videoView + + GeometryReader { geometry in + let ovalCenter = CGPoint( + x: geometry.size.width / 2, + y: geometry.size.height * ovalYRatio + ) + let ovalSize = CGSize(width: ovalWidth, height: ovalHeight) + let ovalTopY = ovalCenter.y - ovalHeight / 2 + + // Static oval overlay (visible before SDK draws its dynamic oval) + if shouldShowStaticOval { + OvalCutoutOverlay(ovalSize: ovalSize, ovalCenter: ovalCenter) + .fill(Color.black, style: FillStyle(eoFill: true)) + + Ellipse() + .stroke(Color.white, lineWidth: theme.oval.strokeWidth) + .frame(width: ovalWidth, height: ovalHeight) + .position(ovalCenter) } - .padding([.leading, .trailing]) - .aspectRatio(3/4, contentMode: .fit) - .frame(maxWidth: .infinity) + + // Instruction pill positioned above the oval + InstructionContainerView(viewModel: viewModel) + .position(x: geometry.size.width / 2, y: ovalTopY - instructionOffset) } + .ignoresSafeArea() } - .edgesIgnoringSafeArea(.all) + } +} + +// MARK: - Oval Cutout Shape + +/// Shape that creates a mask overlay with an oval cutout using even-odd fill. +struct OvalCutoutOverlay: Shape { + let ovalSize: CGSize + let ovalCenter: CGPoint + + func path(in rect: CGRect) -> Path { + var path = Path() + path.addRect(rect) + let ovalRect = CGRect( + x: ovalCenter.x - ovalSize.width / 2, + y: ovalCenter.y - ovalSize.height / 2, + width: ovalSize.width, + height: ovalSize.height + ) + path.addEllipse(in: ovalRect) + return path } } diff --git a/Sources/FaceLiveness/Views/OvalView.swift b/Sources/FaceLiveness/Views/OvalView.swift index 60a6e18..2903af5 100644 --- a/Sources/FaceLiveness/Views/OvalView.swift +++ b/Sources/FaceLiveness/Views/OvalView.swift @@ -10,9 +10,26 @@ import UIKit class OvalView: UIView { let ovalFrame: CGRect + let maskColor: UIColor + let ovalStrokeColor: UIColor + let ovalStrokeWidth: CGFloat - init(frame: CGRect, ovalFrame: CGRect) { + /// When `true`, forces white mask fill regardless of ``maskColor``. + /// Used during the freshness color check to ensure the overlay colors + /// blend correctly against a known white background. + var forceWhiteFill = false + + init( + frame: CGRect, + ovalFrame: CGRect, + maskColor: UIColor = UIColor.white.withAlphaComponent(0.9), + strokeColor: UIColor = .white, + strokeWidth: CGFloat = 8 + ) { self.ovalFrame = ovalFrame + self.maskColor = maskColor + self.ovalStrokeColor = strokeColor + self.ovalStrokeWidth = strokeWidth super.init(frame: frame) backgroundColor = .clear } @@ -22,14 +39,22 @@ class OvalView: UIView { let oval = UIBezierPath(ovalIn: ovalFrame) mask.append(oval.reversing()) - UIColor.white.withAlphaComponent(0.9).setFill() + let fillColor = forceWhiteFill ? UIColor.white : maskColor + fillColor.setFill() mask.fill() UIColor.clear.setFill() - UIColor.white.setStroke() - oval.lineWidth = 8 + ovalStrokeColor.setStroke() + oval.lineWidth = ovalStrokeWidth oval.stroke() } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + setNeedsDisplay() + } + } + required init?(coder: NSCoder) { nil } } diff --git a/Sources/FaceLiveness/Views/RecordingButton.swift b/Sources/FaceLiveness/Views/RecordingButton.swift index 0a15727..76c8c53 100644 --- a/Sources/FaceLiveness/Views/RecordingButton.swift +++ b/Sources/FaceLiveness/Views/RecordingButton.swift @@ -8,6 +8,8 @@ import SwiftUI struct RecordingButton: View { + @Environment(\.livenessTheme) var theme + var body: some View { VStack(alignment: .center) { Circle() @@ -19,7 +21,7 @@ struct RecordingButton: View { } .padding([.top, .bottom], 12) .padding([.leading, .trailing], 8) - .background(Color.livenessBackground) + .background(theme.colors.surface) .cornerRadius(8) } } diff --git a/Sources/FaceLiveness/Views/WarningBox.swift b/Sources/FaceLiveness/Views/WarningBox.swift index 050c2aa..22215b4 100644 --- a/Sources/FaceLiveness/Views/WarningBox.swift +++ b/Sources/FaceLiveness/Views/WarningBox.swift @@ -9,6 +9,7 @@ import SwiftUI struct WarningBox: View { @State var isPresentingPopover = false + @Environment(\.livenessTheme) var theme let titleText: String let bodyText: String let popoverContent: PopoverView @@ -28,17 +29,17 @@ struct WarningBox: View { VStack(alignment: .leading) { Text(titleText) .fontWeight(.semibold) - .foregroundColor(.livenessWarningLabel) + .foregroundColor(theme.colors.onErrorContainer) Text(bodyText) - .foregroundColor(.livenessWarningLabel) + .foregroundColor(theme.colors.onErrorContainer) } Spacer() Button( action: { isPresentingPopover = true }, label: { Image(systemName: "info.circle") - .foregroundColor(.livenessWarningLabel) + .foregroundColor(theme.colors.onErrorContainer) .frame(width: 20, height: 20) } ) @@ -53,7 +54,7 @@ struct WarningBox: View { .padding() .background( Rectangle() - .foregroundColor(.livenessWarningBackground) + .foregroundColor(theme.colors.errorContainer) .cornerRadius(6) ) }