Skip to content

Commit d26d776

Browse files
[FSSDK-12086] chore: add nested object support into event meta for Swift (#92)
* Add support nested object into event meta * clean up * Add test cases for iOS
1 parent 725ec50 commit d26d776

File tree

6 files changed

+639
-93
lines changed

6 files changed

+639
-93
lines changed

example/lib/main.dart

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,18 @@ class _MyAppState extends State<MyApp> {
2929
OptimizelyDecideOption.includeReasons,
3030
OptimizelyDecideOption.excludeVariables
3131
};
32+
3233
final customLogger = CustomLogger();
3334

34-
var flutterSDK = OptimizelyFlutterSdk("X9mZd2WDywaUL9hZXyh9A",
35-
datafilePeriodicDownloadInterval: 10 * 60,
36-
eventOptions: const EventOptions(
37-
batchSize: 1, timeInterval: 60, maxQueueSize: 10000),
38-
defaultLogLevel: OptimizelyLogLevel.debug,
39-
defaultDecideOptions: defaultOptions,
40-
logger: customLogger,
41-
);
35+
var flutterSDK = OptimizelyFlutterSdk(
36+
"X9mZd2WDywaUL9hZXyh9A",
37+
datafilePeriodicDownloadInterval: 10 * 60,
38+
eventOptions: const EventOptions(
39+
batchSize: 1, timeInterval: 60, maxQueueSize: 10000),
40+
defaultLogLevel: OptimizelyLogLevel.debug,
41+
defaultDecideOptions: defaultOptions,
42+
logger: customLogger,
43+
);
4244
var response = await flutterSDK.initializeClient();
4345

4446
setState(() {
@@ -60,7 +62,7 @@ class _MyAppState extends State<MyApp> {
6062
"stringValue": "121"
6163
});
6264

63-
// To add decide listener
65+
// Add decide listener
6466
var decideListenerId =
6567
await flutterSDK.addDecisionNotificationListener((notification) {
6668
print("Parsed decision event ....................");
@@ -73,17 +75,13 @@ class _MyAppState extends State<MyApp> {
7375
Set<OptimizelyDecideOption> options = {
7476
OptimizelyDecideOption.ignoreUserProfileService,
7577
};
78+
7679
// Decide call
7780
var decideResponse = await userContext.decide('flag1', options);
7881
uiResponse +=
7982
"\nFirst decide call variationKey: ${decideResponse.decision!.variationKey}";
8083

81-
// should return following response without forced decision
82-
// flagKey: flag1
83-
// ruleKey: default-rollout-7371-20896892800
84-
// variationKey: off
85-
86-
// Setting forced decision
84+
// Set forced decision
8785
await userContext.setForcedDecision(
8886
OptimizelyDecisionContext("flag1", "flag1_experiment"),
8987
OptimizelyForcedDecision("variation_a"));
@@ -93,11 +91,6 @@ class _MyAppState extends State<MyApp> {
9391
uiResponse +=
9492
"\nSecond decide call variationKey: ${decideResponse.decision!.variationKey}";
9593

96-
// should return following response with forced decision
97-
// flagKey: flag1
98-
// ruleKey: flag1_experiment
99-
// variationKey: variation_a
100-
10194
// removing forced decision
10295
await userContext.removeForcedDecision(
10396
OptimizelyDecisionContext("flag1", "flag1_experiment"));
@@ -111,14 +104,6 @@ class _MyAppState extends State<MyApp> {
111104
uiResponse = uiResponse;
112105
});
113106

114-
// should return original response without forced decision
115-
// flagKey: flag1
116-
// ruleKey: default-rollout-7371-20896892800
117-
// variationKey: off
118-
119-
// To cancel decide listener
120-
// await flutterSDK.removeNotificationListener(decideListenerId);
121-
122107
// To add track listener
123108
var trackListenerID =
124109
await flutterSDK.addTrackNotificationListener((notification) {
@@ -137,12 +122,19 @@ class _MyAppState extends State<MyApp> {
137122
print("log event notification received");
138123
});
139124

140-
// Track call
125+
// Track call with nested objects
141126
response = await userContext.trackEvent("myevent", {
142-
"age": 20,
143-
"doubleValue": 12.12,
144-
"boolValue": false,
145-
"stringValue": "121"
127+
"revenue": 99.99,
128+
"user": {
129+
"id": "user123",
130+
"premium": true,
131+
"tags": ["vip", "loyal"]
132+
},
133+
"items": [
134+
{"name": "Product A", "quantity": 2, "price": 49.99},
135+
{"name": "Product B", "quantity": 1, "price": 50.00}
136+
],
137+
"metadata": {"source": "mobile_app", "platform": "ios"}
146138
});
147139

148140
// To cancel track listener

ios/Classes/HelperClasses/Constants.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,6 @@ struct TypeValue {
139139
static let int = "int"
140140
static let double = "double"
141141
static let bool = "bool"
142+
static let map = "map"
143+
static let list = "list"
142144
}

ios/Classes/HelperClasses/Utils.swift

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,36 +26,59 @@ public class Utils: NSObject {
2626
}
2727
var typedDictionary = [String: Any]()
2828
for (k,v) in args {
29-
if let typedValue = v as? Dictionary<String, Any?>, let value = typedValue["value"] as? Any, let type = typedValue["type"] as? String {
30-
switch type {
31-
case TypeValue.string:
32-
if let strValue = value as? String {
33-
typedDictionary[k] = strValue
34-
}
35-
break
36-
case TypeValue.int:
37-
if let intValue = value as? Int {
38-
typedDictionary[k] = NSNumber(value: intValue).intValue
39-
}
40-
break
41-
case TypeValue.double:
42-
if let doubleValue = value as? Double {
43-
typedDictionary[k] = NSNumber(value: doubleValue).doubleValue
44-
}
45-
break
46-
case TypeValue.bool:
47-
if let booleanValue = value as? Bool {
48-
typedDictionary[k] = NSNumber(value: booleanValue).boolValue
49-
}
50-
break
51-
default:
52-
break
53-
}
29+
if let processedValue = processTypedValue(v) {
30+
typedDictionary[k] = processedValue
5431
}
55-
continue
5632
}
5733
return typedDictionary
5834
}
35+
36+
/// Recursively processes typed values from Flutter to native Swift types
37+
private static func processTypedValue(_ value: Any?) -> Any? {
38+
guard let typedValue = value as? Dictionary<String, Any?>,
39+
let val = typedValue["value"],
40+
let type = typedValue["type"] as? String else {
41+
return nil
42+
}
43+
44+
switch type {
45+
case TypeValue.string:
46+
return val as? String
47+
case TypeValue.int:
48+
if let intValue = val as? Int {
49+
return NSNumber(value: intValue).intValue
50+
}
51+
return nil
52+
case TypeValue.double:
53+
if let doubleValue = val as? Double {
54+
return NSNumber(value: doubleValue).doubleValue
55+
}
56+
return nil
57+
case TypeValue.bool:
58+
if let booleanValue = val as? Bool {
59+
return NSNumber(value: booleanValue).boolValue
60+
}
61+
return nil
62+
case TypeValue.map:
63+
guard let nestedMap = val as? Dictionary<String, Any?> else {
64+
return nil
65+
}
66+
var result = [String: Any]()
67+
for (k, v) in nestedMap {
68+
if let processedValue = processTypedValue(v) {
69+
result[k] = processedValue
70+
}
71+
}
72+
return result
73+
case TypeValue.list:
74+
guard let nestedArray = val as? [Any?] else {
75+
return nil
76+
}
77+
return nestedArray.compactMap { processTypedValue($0) }
78+
default:
79+
return nil
80+
}
81+
}
5982

6083
/// Returns callback required for LogEventListener
6184
static func getLogEventCallback(id: Int, sdkKey: String) -> LogEventListener {

lib/src/utils/constants.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class Constants {
2020
static const String intType = "int";
2121
static const String doubleType = "double";
2222
static const String boolType = "bool";
23+
static const String mapType = "map";
24+
static const String listType = "list";
2325

2426
// Supported Method Names
2527
static const String initializeMethod = "initialize";

lib/src/utils/utils.dart

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,17 @@ class Utils {
3333
OptimizelySegmentOption.resetCache: "resetCache",
3434
};
3535

36-
static Map<String, dynamic> convertToTypedMap(Map<String, dynamic> map) {
36+
/// Converts a map to platform-specific typed format
37+
///
38+
/// On iOS, returns a typed map with type information for proper native conversion.
39+
/// On Android, returns the original primitive map.
40+
///
41+
/// The [forceIOSFormat] parameter is used for testing purposes only to test
42+
/// iOS format conversion without running on actual iOS platform.
43+
static Map<String, dynamic> convertToTypedMap(
44+
Map<String, dynamic> map, {
45+
bool forceIOSFormat = false,
46+
}) {
3747
if (map.isEmpty) {
3848
return map;
3949
}
@@ -43,48 +53,78 @@ class Utils {
4353
// Only keep primitive values
4454
Map<String, dynamic> primitiveMap = {};
4555
for (MapEntry e in map.entries) {
46-
if (e.value is String) {
56+
dynamic processedValue = _processValue(e.value);
57+
if (processedValue != null) {
4758
primitiveMap[e.key] = e.value;
48-
typedMap[e.key] = {
49-
Constants.value: e.value,
50-
Constants.type: Constants.stringType
51-
};
52-
continue;
59+
typedMap[e.key] = processedValue;
5360
}
54-
if (e.value is double) {
55-
primitiveMap[e.key] = e.value;
56-
typedMap[e.key] = {
57-
Constants.value: e.value,
58-
Constants.type: Constants.doubleType
59-
};
60-
continue;
61-
}
62-
if (e.value is int) {
63-
primitiveMap[e.key] = e.value;
64-
typedMap[e.key] = {
65-
Constants.value: e.value,
66-
Constants.type: Constants.intType
67-
};
68-
continue;
69-
}
70-
if (e.value is bool) {
71-
primitiveMap[e.key] = e.value;
72-
typedMap[e.key] = {
73-
Constants.value: e.value,
74-
Constants.type: Constants.boolType
75-
};
76-
continue;
77-
}
78-
// ignore: avoid_print
79-
print('Unsupported value type for key: ${e.key}.');
8061
}
8162

82-
if (Platform.isIOS) {
63+
if (Platform.isIOS || forceIOSFormat) {
8364
return typedMap;
8465
}
8566
return primitiveMap;
8667
}
8768

69+
/// Recursively processes values to add type information for iOS
70+
static dynamic _processValue(dynamic value) {
71+
if (value is String) {
72+
return {
73+
Constants.value: value,
74+
Constants.type: Constants.stringType
75+
};
76+
}
77+
if (value is double) {
78+
return {
79+
Constants.value: value,
80+
Constants.type: Constants.doubleType
81+
};
82+
}
83+
if (value is int) {
84+
return {
85+
Constants.value: value,
86+
Constants.type: Constants.intType
87+
};
88+
}
89+
if (value is bool) {
90+
return {
91+
Constants.value: value,
92+
Constants.type: Constants.boolType
93+
};
94+
}
95+
if (value is Map) {
96+
// Handle nested maps
97+
Map<String, dynamic> nestedMap = {};
98+
(value as Map).forEach((k, v) {
99+
dynamic processedValue = _processValue(v);
100+
if (processedValue != null) {
101+
nestedMap[k.toString()] = processedValue;
102+
}
103+
});
104+
return {
105+
Constants.value: nestedMap,
106+
Constants.type: Constants.mapType
107+
};
108+
}
109+
if (value is List) {
110+
// Handle arrays
111+
List<dynamic> nestedList = [];
112+
for (var item in value) {
113+
dynamic processedValue = _processValue(item);
114+
if (processedValue != null) {
115+
nestedList.add(processedValue);
116+
}
117+
}
118+
return {
119+
Constants.value: nestedList,
120+
Constants.type: Constants.listType
121+
};
122+
}
123+
// ignore: avoid_print
124+
print('Unsupported value type: ${value.runtimeType}');
125+
return null;
126+
}
127+
88128
static List<String> convertDecideOptions(
89129
Set<OptimizelyDecideOption> options) {
90130
return options.map((option) => Utils.decideOptions[option]!).toList();

0 commit comments

Comments
 (0)