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
6 changes: 2 additions & 4 deletions Sources/FormbricksSDK/Manager/PresentSurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,9 @@ final class PresentSurveyManager {
let view = FormbricksView(
viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id))
let vc = UIHostingController(rootView: view)
vc.modalPresentationStyle = .pageSheet
vc.modalPresentationStyle = .overFullScreen
vc.modalTransitionStyle = .crossDissolve
vc.view.backgroundColor = .clear
if let sheet = vc.sheetPresentationController {
sheet.detents = [.large()]
}
self.viewController = vc
presenter.present(
vc, animated: true,
Expand Down
12 changes: 7 additions & 5 deletions Sources/FormbricksSDK/Manager/SurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ final class SurveyManager {
return true
}

// Include surveys with segments but no filters
return segment.filters.isEmpty
// Include surveys with segments but no filters. `hasFilters`
// is decoded directly from the server response, or derived
// from a legacy cached `filters` array (see Segment decoder).
return !segment.hasFilters
}
}

Expand Down Expand Up @@ -101,7 +103,7 @@ final class SurveyManager {
// Display percentage
let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage)
if let survey = firstSurveyWithActionClass, !shouldDisplay {
Formbricks.logger?.info("Skipping survey \(survey.name) due to display percentage restriction.")
Formbricks.logger?.info("Skipping survey \(survey.id) due to display percentage restriction.")
return
}
let isMultiLangSurvey = firstSurveyWithActionClass?.languages?.count ?? 0 > 1
Expand All @@ -110,7 +112,7 @@ final class SurveyManager {
guard let survey = firstSurveyWithActionClass else {return}
let currentLanguage = Formbricks.language
guard let languageCode = getLanguageCode(survey: survey, language: currentLanguage) else {
Formbricks.logger?.error("Survey \(survey.name) is not available in language “\(currentLanguage)”. Skipping.")
Formbricks.logger?.error("Survey \(survey.id) is not available in language “\(currentLanguage)”. Skipping.")
return
}

Expand All @@ -122,7 +124,7 @@ final class SurveyManager {
isShowingSurvey = true
let timeout = survey.delay ?? 0
if timeout > 0 {
Formbricks.logger?.info("Delaying survey \(survey.name) by \(timeout) seconds")
Formbricks.logger?.info("Delaying survey \(survey.id) by \(timeout) seconds")
}
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
guard let self = self else { return }
Expand Down
4 changes: 3 additions & 1 deletion Sources/FormbricksSDK/Model/Workspace/Survey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ struct ProjectOverwrites: Codable {

struct Survey: Codable {
let id: String
let name: String
// `name` intentionally omitted — internal label, not returned by the
// public client API. Cached payloads from older SDK versions may still
// include it in JSON; Codable will quietly ignore unknown keys.
let triggers: [Trigger]?
let recontactDays: Int?
let displayLimit: Int?
Expand Down
232 changes: 20 additions & 212 deletions Sources/FormbricksSDK/Model/Workspace/Surveys/Segment.swift
Original file line number Diff line number Diff line change
@@ -1,232 +1,40 @@
import Foundation

// MARK: - Connector

enum SegmentConnector: String, Codable {
case and
case or
}

// MARK: - Filter Operators

/// Combined operator set for all filter types
enum FilterOperator: String, Codable {
// Base / Arithmetic
case lessThan
case lessEqual
case greaterThan
case greaterEqual
case equals
case notEquals
// Attribute / String
case contains
case doesNotContain
case startsWith
case endsWith
// Existence
case isSet
case isNotSet
// Segment membership
case userIsIn
case userIsNotIn
}

// MARK: - Filter Value

enum SegmentFilterValue: Codable {
case string(String)
case number(Double)

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let num = try? container.decode(Double.self) {
self = .number(num)
} else if let str = try? container.decode(String.self) {
self = .string(str)
} else {
throw DecodingError.typeMismatch(
SegmentFilterValue.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Value is neither Double nor String"
)
)
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .number(let num):
try container.encode(num)
case .string(let str):
try container.encode(str)
}
}
}

// MARK: - Root

enum SegmentFilterRoot: Codable {
case attribute(contactAttributeKey: String)
case person(personIdentifier: String)
case segment(segmentId: String)
case device(deviceType: String)

private enum CodingKeys: String, CodingKey {
case type
case contactAttributeKey
case personIdentifier
case segmentId
case deviceType
}

private enum RootType: String, Codable {
case attribute
case person
case segment
case device
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(RootType.self, forKey: .type)
switch type {
case .attribute:
let key = try container.decode(String.self, forKey: .contactAttributeKey)
self = .attribute(contactAttributeKey: key)
case .person:
let id = try container.decode(String.self, forKey: .personIdentifier)
self = .person(personIdentifier: id)
case .segment:
let id = try container.decode(String.self, forKey: .segmentId)
self = .segment(segmentId: id)
case .device:
let type = try container.decode(String.self, forKey: .deviceType)
self = .device(deviceType: type)
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .attribute(let key):
try container.encode(RootType.attribute, forKey: .type)
try container.encode(key, forKey: .contactAttributeKey)
case .person(let id):
try container.encode(RootType.person, forKey: .type)
try container.encode(id, forKey: .personIdentifier)
case .segment(let id):
try container.encode(RootType.segment, forKey: .type)
try container.encode(id, forKey: .segmentId)
case .device(let type):
try container.encode(RootType.device, forKey: .type)
try container.encode(type, forKey: .deviceType)
}
}
}

// MARK: - Qualifier

struct SegmentFilterQualifier: Codable {
let filterOperator: FilterOperator

private enum CodingKeys: String, CodingKey {
case filterOperator = "operator"
}
}

// MARK: - Primitive Filter

struct SegmentPrimitiveFilter: Codable {
let id: String
let root: SegmentFilterRoot
let value: SegmentFilterValue
let qualifier: SegmentFilterQualifier

// Add run-time refinements if needed
}

// MARK: - Recursive Filter Resource

enum SegmentFilterResource: Codable {
case primitive(SegmentPrimitiveFilter)
case group([SegmentFilter])

init(from decoder: Decoder) throws {
// Try primitive first
if let prim = try? SegmentPrimitiveFilter(from: decoder) {
self = .primitive(prim)
} else {
let nested = try [SegmentFilter](from: decoder)
self = .group(nested)
}
}

func encode(to encoder: Encoder) throws {
switch self {
case .primitive(let prim):
try prim.encode(to: encoder)
case .group(let arr):
try arr.encode(to: encoder)
}
}
}

// MARK: - Base Filter (node)

struct SegmentFilter: Codable {
let id: String
let connector: SegmentConnector?
let resource: SegmentFilterResource
}

// MARK: - Segment Model

/// Public client API returns the minimal `{ id, hasFilters }` shape — full
/// filter logic (titles, descriptions, conditions) is evaluated server-side
/// and must not reach the device.
///
/// The custom decoder also accepts legacy cached payloads that still carry a
/// `filters` array (written by older SDK versions before the API was slimmed
/// down). In that case `hasFilters` is derived from the array length so
/// anonymous users continue to be excluded from segment-targeted surveys
/// during the cache window after an SDK upgrade.
struct Segment: Codable {
let id: String
let title: String
let description: String?
let isPrivate: Bool
let filters: [SegmentFilter]
let workspaceId: String?
let createdAt: Date
let updatedAt: Date
let surveys: [String]
let hasFilters: Bool

private enum CodingKeys: String, CodingKey {
case id, title, description, filters, surveys, createdAt, updatedAt
case isPrivate = "isPrivate"
case workspaceId
case environmentId
case id, hasFilters, filters
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
description = try container.decodeIfPresent(String.self, forKey: .description)
isPrivate = try container.decode(Bool.self, forKey: .isPrivate)
filters = try container.decode([SegmentFilter].self, forKey: .filters)
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
surveys = try container.decode([String].self, forKey: .surveys)
// Server may send `workspaceId` (new) or `environmentId` (legacy). Field is
// informational only — not read by SDK logic — so keep it optional.
workspaceId = try container.decodeIfPresent(String.self, forKey: .workspaceId)
?? container.decodeIfPresent(String.self, forKey: .environmentId)

if let serverHasFilters = try container.decodeIfPresent(Bool.self, forKey: .hasFilters) {
hasFilters = serverHasFilters
} else if let legacyFilters = try container.decodeIfPresent([AnyDecodable].self, forKey: .filters) {
hasFilters = !legacyFilters.isEmpty
} else {
hasFilters = false
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(title, forKey: .title)
try container.encodeIfPresent(description, forKey: .description)
try container.encode(isPrivate, forKey: .isPrivate)
try container.encode(filters, forKey: .filters)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(surveys, forKey: .surveys)
try container.encodeIfPresent(workspaceId, forKey: .workspaceId)
try container.encode(hasFilters, forKey: .hasFilters)
}
}
4 changes: 2 additions & 2 deletions Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ private extension FormbricksViewModel {
<title>Formbricks WebView Survey</title>
</head>

<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">
<div id="formbricks-react-native" style="width: 100%;"></div>
<body style="overflow: hidden; height: 100vh; margin: 0; background: transparent;">
<div id="formbricks-react-native" style="width: 100%; height: 100%;"></div>
</body>

<script type="text/javascript">
Expand Down
1 change: 0 additions & 1 deletion Tests/FormbricksSDKTests/FormbricksSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,6 @@ final class FormbricksSDKTests: XCTestCase {
// getLanguageCode coverage
let survey = Survey(
id: "1",
name: "Test Survey",
triggers: nil,
recontactDays: nil,
displayLimit: nil,
Expand Down
Loading