diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c280fa46..c56c9da2 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -34,7 +34,7 @@ jobs: -scheme 'FormbricksSDK' \ -sdk iphonesimulator \ -config Debug \ - -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.4' \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.5' \ -derivedDataPath build \ -enableCodeCoverage YES diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index b92c79ab..d63a085e 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -13,14 +13,14 @@ final class PresentSurveyManager { private weak var viewController: UIViewController? /// Present the webview - func present(environmentResponse: EnvironmentResponse, id: String, completion: ((Bool) -> Void)? = nil) { + func present(environmentResponse: EnvironmentResponse, id: String, overlay: SurveyOverlay = .none, completion: ((Bool) -> Void)? = nil) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if let window = UIApplication.safeKeyWindow { - let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id)) + let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id)) let vc = UIHostingController(rootView: view) vc.modalPresentationStyle = .overCurrentContext - vc.view.backgroundColor = UIColor.gray.withAlphaComponent(0.6) + vc.view.backgroundColor = Self.backgroundColor(for: overlay) if let presentationController = vc.presentationController as? UISheetPresentationController { presentationController.detents = [.large()] } @@ -34,6 +34,18 @@ final class PresentSurveyManager { } } + /// Returns the appropriate background color for the given overlay style. + static func backgroundColor(for overlay: SurveyOverlay) -> UIColor { + switch overlay { + case .dark: + return UIColor(white: 0.2, alpha: 0.6) + case .light: + return UIColor(white: 0.6, alpha: 0.4) + case .none: + return .clear + } + } + /// Dismiss the webview func dismissView() { viewController?.dismiss(animated: true) diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index fa2f0c90..c51509ee 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -120,7 +120,8 @@ final class SurveyManager { DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in guard let self = self else { return } if let environmentResponse = self.environmentResponse { - self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in + let overlay = self.resolveOverlay(for: survey) + self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id, overlay: overlay) { success in if !success { self.isShowingSurvey = false } @@ -189,9 +190,10 @@ private extension SurveyManager { /// The view controller is presented over the current context. func showSurvey(withId id: String) { if let environmentResponse = environmentResponse { - presentSurveyManager.present(environmentResponse: environmentResponse, id: id) + let survey = environmentResponse.data.data.surveys?.first(where: { $0.id == id }) + let overlay = resolveOverlay(for: survey) + presentSurveyManager.present(environmentResponse: environmentResponse, id: id, overlay: overlay) } - } /// Starts a timer to refresh the environment state after the given timeout (`expiresAt`). @@ -345,6 +347,15 @@ extension SurveyManager { return entry.language.code } + /// Resolves the overlay style for the given survey, falling back to the project-level default. + /// Survey-level `projectOverwrites.overlay` takes precedence over `project.overlay`. + func resolveOverlay(for survey: Survey?) -> SurveyOverlay { + if let surveyOverlay = survey?.projectOverwrites?.overlay { + return surveyOverlay + } + return environmentResponse?.data.data.project.overlay ?? .none + } + /// Filters the surveys based on the user's segments. func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] { return surveys.filter { survey in diff --git a/Sources/FormbricksSDK/Model/Environment/Project/Project.swift b/Sources/FormbricksSDK/Model/Environment/Project/Project.swift index cb89287d..cd92c094 100644 --- a/Sources/FormbricksSDK/Model/Environment/Project/Project.swift +++ b/Sources/FormbricksSDK/Model/Environment/Project/Project.swift @@ -2,7 +2,7 @@ struct Project: Codable { let id: String? let recontactDays: Int? let clickOutsideClose: Bool? - let darkOverlay: Bool? + let overlay: SurveyOverlay? let placement: String? let inAppSurveyBranding: Bool? let styling: Styling? diff --git a/Sources/FormbricksSDK/Model/Environment/Survey.swift b/Sources/FormbricksSDK/Model/Environment/Survey.swift index 37b2c25c..3544a8d5 100644 --- a/Sources/FormbricksSDK/Model/Environment/Survey.swift +++ b/Sources/FormbricksSDK/Model/Environment/Survey.swift @@ -35,12 +35,19 @@ enum Placement: String, Codable { case center = "center" } +/// Defines the overlay style displayed behind a survey modal. +enum SurveyOverlay: String, Codable { + case none = "none" + case light = "light" + case dark = "dark" +} + struct ProjectOverwrites: Codable { let brandColor: String? let highlightBorderColor: String? let placement: Placement? let clickOutsideClose: Bool? - let darkOverlay: Bool? + let overlay: SurveyOverlay? } struct Survey: Codable { diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift index 67541a1c..439c78b5 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -107,7 +107,8 @@ private class WebViewData { data["placement"] = project.placement } - data["darkOverlay"] = matchedSurvey?.projectOverwrites?.darkOverlay ?? project.darkOverlay + data["clickOutside"] = matchedSurvey?.projectOverwrites?.clickOutsideClose ?? project.clickOutsideClose ?? false + data["overlay"] = (matchedSurvey?.projectOverwrites?.overlay ?? project.overlay ?? .none).rawValue let isMultiLangSurvey = (matchedSurvey?.languages?.count ?? 0) > 1 diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index b24d1356..2e055a3c 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -261,6 +261,148 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertNil(manager.getLanguageCode(survey: survey, language: "spanish")) } + // MARK: - PresentSurveyManager overlay background color tests + + func testBackgroundColorForDarkOverlay() { + let color = PresentSurveyManager.backgroundColor(for: .dark) + var white: CGFloat = 0 + var alpha: CGFloat = 0 + color.getWhite(&white, alpha: &alpha) + XCTAssertEqual(white, 0.2, accuracy: 0.01, "Dark overlay should use 0.2 white") + XCTAssertEqual(alpha, 0.6, accuracy: 0.01, "Dark overlay should use 0.6 alpha") + } + + func testBackgroundColorForLightOverlay() { + let color = PresentSurveyManager.backgroundColor(for: .light) + var white: CGFloat = 0 + var alpha: CGFloat = 0 + color.getWhite(&white, alpha: &alpha) + XCTAssertEqual(white, 0.6, accuracy: 0.01, "Light overlay should use 0.6 white") + XCTAssertEqual(alpha, 0.4, accuracy: 0.01, "Light overlay should use 0.4 alpha") + } + + func testBackgroundColorForNoneOverlay() { + let color = PresentSurveyManager.backgroundColor(for: .none) + XCTAssertEqual(color, .clear, "None overlay should return clear color") + } + + func testPresentWithOverlayCompletesInHeadlessEnvironment() { + // In a headless test environment there is no key window, so present() should + // call the completion with false for every overlay variant. + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let env = Formbricks.surveyManager?.environmentResponse else { + XCTFail("Missing environmentResponse") + return + } + + let manager = PresentSurveyManager() + + for overlay in [SurveyOverlay.none, .light, .dark] { + let presentExpectation = expectation(description: "Present with \(overlay.rawValue) overlay") + manager.present(environmentResponse: env, id: surveyID, overlay: overlay) { success in + // No key window in headless tests → completion(false) + XCTAssertFalse(success, "Presentation should fail in headless environment for overlay: \(overlay.rawValue)") + presentExpectation.fulfill() + } + wait(for: [presentExpectation], timeout: 2.0) + } + } + + // MARK: - SurveyManager.resolveOverlay tests + + func testResolveOverlayUsesSurveyOverwrite() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let manager = Formbricks.surveyManager else { + XCTFail("Missing surveyManager") + return + } + + // The mock survey has projectOverwrites.overlay = "dark" + let surveyWithOverwrite = manager.environmentResponse?.data.data.surveys?.first(where: { $0.id == surveyID }) + XCTAssertNotNil(surveyWithOverwrite) + XCTAssertEqual(manager.resolveOverlay(for: surveyWithOverwrite), .dark, + "Should use survey-level projectOverwrites overlay") + } + + func testResolveOverlayFallsBackToProjectDefault() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let manager = Formbricks.surveyManager else { + XCTFail("Missing surveyManager") + return + } + + // A survey without projectOverwrites should fall back to project.overlay ("none" in mock) + let surveyWithoutOverwrite = Survey( + id: "no-overwrite", + name: "No Overwrite", + triggers: nil, + recontactDays: nil, + displayLimit: nil, + delay: nil, + displayPercentage: nil, + displayOption: .respondMultiple, + segment: nil, + styling: nil, + languages: nil, + projectOverwrites: nil + ) + XCTAssertEqual(manager.resolveOverlay(for: surveyWithoutOverwrite), .none, + "Should fall back to project-level overlay when no survey overwrite exists") + } + + func testResolveOverlayReturnsNoneForNilSurvey() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let manager = Formbricks.surveyManager else { + XCTFail("Missing surveyManager") + return + } + + XCTAssertEqual(manager.resolveOverlay(for: nil), .none, + "Should return .none when survey is nil") + } + + // MARK: - WebView data tests + func testWebViewDataUsesSurveyOverwrites() { // Setup SDK with mock service loading Environment.json (which now includes projectOverwrites) let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) @@ -305,8 +447,10 @@ final class FormbricksSDKTests: XCTestCase { return } - // placement should come from survey.projectOverwrites (center), and darkOverlay true + // placement should come from survey.projectOverwrites (center), overlay should be "dark", + // and clickOutside should be false (from survey.projectOverwrites.clickOutsideClose) XCTAssertEqual(object["placement"] as? String, "center") - XCTAssertEqual(object["darkOverlay"] as? Bool, true) + XCTAssertEqual(object["overlay"] as? String, "dark") + XCTAssertEqual(object["clickOutside"] as? Bool, false) } } diff --git a/Tests/FormbricksSDKTests/Mock/Environment.json b/Tests/FormbricksSDKTests/Mock/Environment.json index 8e900f3d..fa8ec1dc 100644 --- a/Tests/FormbricksSDKTests/Mock/Environment.json +++ b/Tests/FormbricksSDKTests/Mock/Environment.json @@ -12,7 +12,7 @@ ], "project": { "clickOutsideClose": true, - "darkOverlay": false, + "overlay": "none", "id": "cm6ovvfnv0003sf0k7zi8r3ac", "inAppSurveyBranding": true, "placement": "bottomRight", @@ -57,7 +57,7 @@ "name": "Start from scratch", "projectOverwrites": { "placement": "center", - "darkOverlay": true, + "overlay": "dark", "clickOutsideClose": false, "brandColor": "#ff0000", "highlightBorderColor": "#00ff00"