Skip to content
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ let package = Package(
dependencies: ["FormbricksSDK"],
resources: [
.process("Mock/User.json"),
.process("Mock/UserWithErrors.json"),
.process("Mock/UserWithMessages.json"),
.process("Mock/UserWithErrorsAndMessages.json"),
.process("Mock/Environment.json"),
]
)
Expand Down
82 changes: 73 additions & 9 deletions Sources/FormbricksSDK/Formbricks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import Network
if let attributes = config.attributes, !attributes.isEmpty {
userManager?.set(attributes: attributes)
}
if let language = config.attributes?["language"] {
if let language = config.attributes?["language"]?.stringValue {
userManager?.set(language: language)
self.language = language
}
Expand All @@ -96,6 +96,11 @@ import Network

/**
Sets the user id for the current user with the given `String`.

- If the same userId is already set, this is a no-op.
- If a different userId is already set, the previous user state is cleaned up first
before setting the new userId.

The SDK must be initialized before calling this method.

Example:
Expand All @@ -110,21 +115,28 @@ import Network
return
}

if let existing = userManager?.userId, !existing.isEmpty {
logger?.error("A userId is already set (\"\(existing)\") – please call Formbricks.logout() before setting a new one.")
// If the same userId is already set, no-op
if let existing = userManager?.userId, existing == userId {
logger?.debug("UserId is already set to the same value, skipping")
return
}

// If a different userId is set, clean up the previous user state first
if let existing = userManager?.userId, !existing.isEmpty {
logger?.debug("Different userId is being set, cleaning up previous user state")
userManager?.logout()
}

userManager?.set(userId: userId)
}

/**
Adds an attribute for the current user with the given `String` value and `String` key.
Adds a string attribute for the current user.
The SDK must be initialized before calling this method.

Example:
```swift
Formbricks.setAttribute("ATTRIBUTE", forKey: "KEY")
Formbricks.setAttribute("John", forKey: "name")
```
*/
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
Expand All @@ -134,19 +146,71 @@ import Network
return
}

userManager?.add(attribute: attribute, forKey: key)
userManager?.add(attribute: .string(attribute), forKey: key)
}

/**
Adds a numeric attribute for the current user.
The SDK must be initialized before calling this method.

Example:
```swift
Formbricks.setAttribute(42.0, forKey: "age")
```
*/
public static func setAttribute(_ attribute: Double, forKey key: String) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
Formbricks.logger?.error(error.message)
return
}

userManager?.add(attribute: .number(attribute), forKey: key)
}

/**
Adds a date attribute for the current user.
The date is converted to an ISO 8601 string. The backend will detect the format and treat it as a date type.
The SDK must be initialized before calling this method.

Example:
```swift
Formbricks.setAttribute(Date(), forKey: "signupDate")
```
*/
public static func setAttribute(_ attribute: Date, forKey key: String) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
Formbricks.logger?.error(error.message)
return
}

userManager?.add(attribute: .string(ISO8601DateFormatter().string(from: attribute)), forKey: key)
}

/**
Sets the user attributes for the current user with the given `Dictionary` of `String` values and `String` keys.
Sets the user attributes for the current user.

Attribute types are determined by the value:
- String values -> string attribute
- Number values -> number attribute
- Use ISO 8601 date strings for date attributes

On first write to a new attribute, the type is set based on the value type.
On subsequent writes, the value must match the existing attribute type.

The SDK must be initialized before calling this method.

Example:
```swift
Formbricks.setAttributes(["KEY", "ATTRIBUTE"])
Formbricks.setAttributes([
"name": "John",
"age": 30,
"score": 9.5
])
```
*/
@objc public static func setAttributes(_ attributes: [String : String]) {
public static func setAttributes(_ attributes: [String : AttributeValue]) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
Formbricks.logger?.error(error.message)
Expand Down
25 changes: 19 additions & 6 deletions Sources/FormbricksSDK/Helpers/ConfigBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import Foundation
let appUrl: String
let environmentId: String
let userId: String?
let attributes: [String:String]?
let attributes: [String: AttributeValue]?
let logLevel: LogLevel
/// Optional custom service, injected via Builder
let customService: FormbricksServiceProtocol?

init(appUrl: String, environmentId: String, userId: String?, attributes: [String : String]?, logLevel: LogLevel, customService: FormbricksServiceProtocol?) {
init(appUrl: String, environmentId: String, userId: String?, attributes: [String: AttributeValue]?, logLevel: LogLevel, customService: FormbricksServiceProtocol?) {
self.appUrl = appUrl
self.environmentId = environmentId
self.userId = userId
Expand All @@ -24,7 +24,7 @@ import Foundation
var appUrl: String
var environmentId: String
var userId: String?
var attributes: [String:String] = [:]
var attributes: [String: AttributeValue] = [:]
var logLevel: LogLevel = .error
/// Optional custom service, injected via Builder
var customService: FormbricksServiceProtocol?
Expand All @@ -41,14 +41,27 @@ import Foundation
}

/// Sets the attributes for the Builder object.
@objc public func set(attributes: [String:String]) -> Builder {
///
/// Thanks to `ExpressibleByStringLiteral`, `ExpressibleByIntegerLiteral`,
/// and `ExpressibleByFloatLiteral` conformances on `AttributeValue`,
/// you can use literal syntax:
/// ```swift
/// .set(attributes: ["name": "John", "age": 30])
/// ```
public func set(attributes: [String: AttributeValue]) -> Builder {
self.attributes = attributes
return self
}

/// Sets the attributes for the Builder object using string values (Obj-C compatible).
@objc public func set(stringAttributes: [String: String]) -> Builder {
self.attributes = stringAttributes.mapValues { .string($0) }
return self
}

/// Adds an attribute to the Builder object.
/// Adds a string attribute to the Builder object (Obj-C compatible).
@objc public func add(attribute: String, forKey key: String) -> Builder {
self.attributes[key] = attribute
self.attributes[key] = .string(attribute)
return self
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/FormbricksSDK/Manager/SurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ final class SurveyManager {
let actionClasses = environmentResponse?.data.data.actionClasses ?? []
let codeActionClasses = actionClasses.filter { $0.type == "code" }
guard let actionClass = codeActionClasses.first(where: { $0.key == action }) else {
Formbricks.logger?.error("\(action) action unknown. Please add this action in Formbricks first in order to use it in your code.")
Formbricks.logger?.error("Action with identifier '\(action)' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking.")
return
}

Expand Down
33 changes: 18 additions & 15 deletions Sources/FormbricksSDK/Manager/UserManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ final class UserManager: UserManagerSyncable {
}

/// Starts an update queue with the given attribute.
func add(attribute: String, forKey key: String) {
func add(attribute: AttributeValue, forKey key: String) {
updateQueue?.add(attribute: attribute, forKey: key)
}

/// Starts an update queue with the given attributes.
func set(attributes: [String: String]) {
func set(attributes: [String: AttributeValue]) {
updateQueue?.set(attributes: attributes)
}

Expand Down Expand Up @@ -85,7 +85,7 @@ final class UserManager: UserManagerSyncable {
}

/// Syncs the user state with the server, calls the `self?.surveyManager?.filterSurveys()` method and starts the sync timer.
func syncUser(withId id: String, attributes: [String: String]? = nil) {
func syncUser(withId id: String, attributes: [String: AttributeValue]? = nil) {
service.postUser(id: id, attributes: attributes) { [weak self] result in
switch result {
case .success(let userResponse):
Expand All @@ -100,6 +100,20 @@ final class UserManager: UserManagerSyncable {
let serverLanguage = userResponse.data.state?.data?.language
Formbricks.language = serverLanguage ?? "default"

// Log errors (always visible) - e.g., invalid attribute keys, type mismatches
if let errors = userResponse.data.errors {
for error in errors {
Formbricks.logger?.error(error)
}
}

// Log informational messages (debug only)
if let messages = userResponse.data.messages {
for message in messages {
Formbricks.logger?.debug("User update message: \(message)")
}
}

self?.updateQueue?.reset()
self?.surveyManager?.filterSurveys()
self?.startSyncTimer()
Expand All @@ -111,13 +125,7 @@ final class UserManager: UserManagerSyncable {

/// Logs out the user and clears the user state.
func logout() {
var isUserIdDefined = false

if userId != nil {
isUserIdDefined = true
} else {
Formbricks.logger?.error("no userId is set, please set a userId first using the setUserId function")
}
Formbricks.logger?.debug("Logging out and cleaning user state")

UserDefaults.standard.removeObject(forKey: UserManager.userIdKey)
UserDefaults.standard.removeObject(forKey: UserManager.contactIdKey)
Expand All @@ -141,11 +149,6 @@ final class UserManager: UserManagerSyncable {

// Re-filter surveys for logged out user
surveyManager?.filterSurveys()

if isUserIdDefined {
Formbricks.logger?.debug("Successfully logged out user and reset the user state.")
}

}

func cleanupUpdateQueue() {
Expand Down
88 changes: 88 additions & 0 deletions Sources/FormbricksSDK/Model/User/AttributeValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Foundation

/// Represents a user attribute value that can be a string, number, or date.
///
/// Attribute types are determined by the Swift value type:
/// - String values -> string attribute
/// - Number values -> number attribute
/// - Date values -> date attribute (converted to ISO string)
///
/// On first write to a new attribute, the type is set based on the value type.
/// On subsequent writes, the value must match the existing attribute type.
///
/// Supports literal syntax in dictionaries:
/// ```swift
/// let attributes: [String: AttributeValue] = [
/// "name": "John",
/// "age": 30,
/// "score": 9.5
/// ]
/// ```
public enum AttributeValue: Codable, Equatable {
case string(String)
case number(Double)

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()

if let doubleValue = try? container.decode(Double.self) {
self = .number(doubleValue)
} else if let stringValue = try? container.decode(String.self) {
self = .string(stringValue)
} else {
throw DecodingError.typeMismatch(
AttributeValue.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Expected String or Number for AttributeValue"
)
)
}
}

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

/// The string representation of this attribute value, if it is a string.
public var stringValue: String? {
if case .string(let value) = self {
return value
}
return nil
}

/// The numeric representation of this attribute value, if it is a number.
public var numberValue: Double? {
if case .number(let value) = self {
return value
}
return nil
}
}

// MARK: - Literal conformances for ergonomic dictionary syntax

extension AttributeValue: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self = .string(value)
}
}

extension AttributeValue: ExpressibleByIntegerLiteral {
public init(integerLiteral value: Int) {
self = .number(Double(value))
}
}

extension AttributeValue: ExpressibleByFloatLiteral {
public init(floatLiteral value: Double) {
self = .number(value)
}
}
2 changes: 2 additions & 0 deletions Sources/FormbricksSDK/Model/User/UserResponseData.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
struct UserResponseData: Codable {
let state: UserState?
let messages: [String]?
let errors: [String]?
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ final class PostUserRequest: EncodableRequest<PostUserRequest.Body>, CodableRequ

struct Body: Codable {
let userId: String
let attributes: [String: String]?
let attributes: [String: AttributeValue]?
}


init(userId: String, attributes: [String: String]?) {
init(userId: String, attributes: [String: AttributeValue]?) {
super.init(object: Body(userId: userId, attributes: attributes))
}
}
Loading