Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/sonarqube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 15 additions & 3 deletions Sources/FormbricksSDK/Manager/PresentSurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
}
Expand All @@ -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)
Expand Down
17 changes: 14 additions & 3 deletions Sources/FormbricksSDK/Manager/SurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
9 changes: 8 additions & 1 deletion Sources/FormbricksSDK/Model/Environment/Survey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
148 changes: 146 additions & 2 deletions Tests/FormbricksSDKTests/FormbricksSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
4 changes: 2 additions & 2 deletions Tests/FormbricksSDKTests/Mock/Environment.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"project": {
"clickOutsideClose": true,
"darkOverlay": false,
"overlay": "none",
"id": "cm6ovvfnv0003sf0k7zi8r3ac",
"inAppSurveyBranding": true,
"placement": "bottomRight",
Expand Down Expand Up @@ -57,7 +57,7 @@
"name": "Start from scratch",
"projectOverwrites": {
"placement": "center",
"darkOverlay": true,
"overlay": "dark",
"clickOutsideClose": false,
"brandColor": "#ff0000",
"highlightBorderColor": "#00ff00"
Expand Down