diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dd4091d..8aff7c9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -81,13 +81,15 @@ android { } dependencies { - // REES46 Android SDK (Maven Central). + // REES46 Android SDK (JitPack). // - // Note: the 2.x line in Maven Central uses versions like 2.6.0 (not 2.28.0). - val rees46AndroidSdkVersion = "2.6.0" + // Published from github.com/rees46/android-sdk as `com.github.rees46:android-sdk:`. + // v2.33.0 is the first release that exposes the loyalty manager + // (`SDK.instance.loyaltyManager`). + val rees46AndroidSdkVersion = "v2.33.0" add( "rees46Implementation", - "com.rees46:rees46-sdk:$rees46AndroidSdkVersion", + "com.github.rees46:android-sdk:$rees46AndroidSdkVersion", ) testImplementation("org.jetbrains.kotlin:kotlin-test") diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt index 9a06bfd..30c52f6 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt @@ -305,6 +305,55 @@ class Rees46FlutterSdkPlugin : } } + override fun joinLoyalty( + phone: String, + email: String?, + firstName: String?, + lastName: String?, + callback: (Result) -> Unit, + ) { + if (phone.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "phone is required", null))) + return + } + try { + SDK.instance.loyaltyManager.join( + phone = phone, + email = email, + firstName = firstName, + lastName = lastName, + onSuccess = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("join_loyalty_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("join_loyalty_failed", t.message, null))) + } + } + + override fun getLoyaltyStatus(identifier: String, callback: (Result) -> Unit) { + if (identifier.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "identifier is required", null))) + return + } + try { + SDK.instance.loyaltyManager.getStatus( + identifier = identifier, + onSuccess = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("loyalty_status_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("loyalty_status_failed", t.message, null))) + } + } + override fun getSid(): String = SDK.instance.getSid() override fun getDid(): String? = SDK.instance.getDid() diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt index e4e16b8..ad35a75 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt @@ -131,12 +131,15 @@ data class InitConfig ( } /** - * Wire format for one purchase line (maps to native [PurchaseItemRequest]; quantity not exposed). + * Wire format for one purchase line (maps to native `PurchaseItemRequest`). + * [amount] is the canonical line quantity; native's redundant `quantity` alias + * is intentionally not exposed. * * Generated class from Pigeon that represents data sent in messages. */ data class PurchaseLineItemWire ( val id: String, + /** Number of units of this product in the order (the line quantity). */ val amount: Long, val price: Double, val lineId: String? = null, @@ -355,6 +358,20 @@ interface PersonalizationHostApi { * Dart layer parses the result into [SearchFullResponse]. */ fun searchFull(query: String, paramsJson: String?, callback: (Result) -> Unit) + /** + * Joins the loyalty program (`loyalty/members/join`) and returns the + * response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. + * The shop is identified by the SDK's configured `shop_id`; [phone] is required. + * Dart layer parses the result into [LoyaltyJoinResponse]. + */ + fun joinLoyalty(phone: String, email: String?, firstName: String?, lastName: String?, callback: (Result) -> Unit) + /** + * Returns the loyalty membership status (`loyalty/members/status`) as a JSON + * string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. + * [identifier] is the member identifier (phone). + * Dart layer parses the result into [LoyaltyStatusResponse]. + */ + fun getLoyaltyStatus(identifier: String, callback: (Result) -> Unit) /** [customJson] and [recommendedSourceJson] are JSON object strings or null. */ fun trackPurchase(orderId: String, orderPrice: Double, items: List, deliveryType: String?, deliveryAddress: String?, paymentType: String?, isTaxFree: Boolean, promocode: String?, orderCash: Double?, orderBonuses: Double?, orderDelivery: Double?, orderDiscount: Double?, channel: String?, customJson: String?, recommendedSourceJson: String?, stream: String?, segment: String?, callback: (Result) -> Unit) @@ -610,6 +627,49 @@ interface PersonalizationHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val phoneArg = args[0] as String + val emailArg = args[1] as String? + val firstNameArg = args[2] as String? + val lastNameArg = args[3] as String? + api.joinLoyalty(phoneArg, emailArg, firstNameArg, lastNameArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(PersonalizationApiPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val identifierArg = args[0] as String + api.getLoyaltyStatus(identifierArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(PersonalizationApiPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/example/integration_test/loyalty_call_test.dart b/example/integration_test/loyalty_call_test.dart new file mode 100644 index 0000000..7f27e52 --- /dev/null +++ b/example/integration_test/loyalty_call_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:rees46_sdk/rees46_sdk.dart'; + +/// Real-network integration test for the loyalty methods. +/// +/// Drives the actual native SDK (Android `loyaltyManager`, iOS +/// `joinLoyalty`/`getLoyaltyStatus`) against the live REES46 API using a +/// loyalty-enabled shop. Run on a booted simulator/emulator: +/// +/// flutter test integration_test/loyalty_call_test.dart -d [device-id] +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const shopId = 'c1140c8254976de297c3caf971701a'; + const apiDomain = 'api.rees46.ru'; + const phone = '79991234567'; + + Future initSdk() async { + final sdk = PersonalizationSdk(); + await sdk.initialize( + const SdkInitConfig( + shopId: shopId, + apiDomain: apiDomain, + stream: kIsWeb ? 'web' : 'ios', + enableLogs: true, + autoSendPushToken: false, + sendAdvertisingId: false, + enableAutoPopupPresentation: false, + needReInitialization: false, + ), + ); + // Wait until the native SDK has established a device id (did) — the status + // endpoint requires it, and on Android init resolves it asynchronously. + for (var i = 0; i < 40; i++) { + final did = await sdk.getDid(); + if (did != null && did.isNotEmpty) break; + await Future.delayed(const Duration(milliseconds: 250)); + } + return sdk; + } + + testWidgets('joinLoyalty — real call returns success', (tester) async { + final sdk = await initSdk(); + + final response = await sdk.joinLoyalty(phone: phone); + debugPrint( + 'joinLoyalty -> status=${response.status} payload=${response.payload}', + ); + expect(response.status, equals('success')); + }); + + testWidgets('getLoyaltyStatus — real call returns success', (tester) async { + final sdk = await initSdk(); + + final response = await sdk.getLoyaltyStatus(phone); + debugPrint( + 'getLoyaltyStatus -> status=${response.status} member=${response.member} level=${response.level?.name}', + ); + expect(response.status, equals('success')); + }); +} diff --git a/example/integration_test/loyalty_sdk_test.dart b/example/integration_test/loyalty_sdk_test.dart new file mode 100644 index 0000000..1a1080d --- /dev/null +++ b/example/integration_test/loyalty_sdk_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:rees46_sdk_example/main.dart' as app; + +Future _initializeSdk(PatrolIntegrationTester $) async { + await $.pumpWidgetAndSettle(const app.App()); + await $( + 'Status: Initialized', + ).waitUntilExists(timeout: const Duration(seconds: 30)); + await $('Status: Initialized').scrollTo(scrollDirection: AxisDirection.up); +} + +String _labelText(PatrolIntegrationTester $, String key) { + final widget = $.tester.widget(find.byKey(Key(key))); + return widget.data ?? ''; +} + +void main() { + patrolTest('getLoyaltyStatus — real call returns success', ($) async { + await _initializeSdk($); + + await $(const Key('btn_loyalty_status')).scrollTo(); + await $(const Key('btn_loyalty_status')).tap(); + + await $( + const Key('lbl_loyalty_result'), + ).waitUntilExists(timeout: const Duration(seconds: 30)); + + expect(find.byKey(const Key('lbl_loyalty_error')), findsNothing); + + final result = _labelText($, 'lbl_loyalty_result'); + expect(result, startsWith('status:')); + expect(result, contains('success')); + }); + + patrolTest('joinLoyalty — real call returns success', ($) async { + await _initializeSdk($); + + await $(const Key('btn_join_loyalty')).scrollTo(); + await $(const Key('btn_join_loyalty')).tap(); + + await $( + const Key('lbl_loyalty_result'), + ).waitUntilExists(timeout: const Duration(seconds: 30)); + + expect(find.byKey(const Key('lbl_loyalty_error')), findsNothing); + + final result = _labelText($, 'lbl_loyalty_result'); + expect(result, startsWith('join:')); + expect(result, contains('success')); + }); +} diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index ac25e8f..8d3fb30 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,22 +1,19 @@ PODS: - CocoaAsyncSocket (7.6.5) - Flutter (1.0.0) - - integration_test (0.0.1): - - Flutter - patrol (0.0.1): - CocoaAsyncSocket (~> 7.6) - Flutter - FlutterMacOS - - personalization_flutter_sdk (0.0.1): + - REES46 (3.28.0) + - rees46_sdk (0.0.1): - Flutter - - REES46 (= 3.23.0) - - REES46 (3.23.0) + - REES46 (= 3.28.0) DEPENDENCIES: - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - patrol (from `.symlinks/plugins/patrol/darwin`) - - personalization_flutter_sdk (from `.symlinks/plugins/personalization_flutter_sdk/ios`) + - rees46_sdk (from `.symlinks/plugins/rees46_sdk/ios`) SPEC REPOS: trunk: @@ -26,20 +23,17 @@ SPEC REPOS: EXTERNAL SOURCES: Flutter: :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" patrol: :path: ".symlinks/plugins/patrol/darwin" - personalization_flutter_sdk: - :path: ".symlinks/plugins/personalization_flutter_sdk/ios" + rees46_sdk: + :path: ".symlinks/plugins/rees46_sdk/ios" SPEC CHECKSUMS: CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 - personalization_flutter_sdk: 296eed2fbff082c9697c96d2176b023c173bb375 - REES46: 48ad09f18e2d75a730728c7e10f0328ffac69b08 + REES46: ecba4cbbfd060636541beedff390564561a1a562 + rees46_sdk: 58ed3f7511bca74a71702a41f7d37f757869837b PODFILE CHECKSUM: b6e248ac4c1eff5807c8b044b39b8bc326af3f5f diff --git a/example/lib/main.dart b/example/lib/main.dart index d5e0d17..19590e5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -94,12 +94,19 @@ class _InitPageState extends State { String? _searchInstantError; bool _searchInstantLoading = false; + // Loyalty state + final _loyaltyPhoneController = TextEditingController(text: '79991234567'); + String? _loyaltyResult; + String? _loyaltyError; + bool _loyaltyLoading = false; + @override void dispose() { _recBlockController.dispose(); _productIdController.dispose(); _searchQueryController.dispose(); _searchInstantQueryController.dispose(); + _loyaltyPhoneController.dispose(); super.dispose(); } @@ -337,6 +344,52 @@ class _InitPageState extends State { } } + Future _joinLoyalty() async { + final phone = _loyaltyPhoneController.text.trim(); + if (phone.isEmpty) return; + setState(() { + _loyaltyLoading = true; + _loyaltyError = null; + _loyaltyResult = null; + }); + try { + final response = await _sdk.joinLoyalty(phone: phone); + setState(() { + _loyaltyResult = 'join: status=${response.status}'; + _loyaltyLoading = false; + }); + } catch (e) { + setState(() { + _loyaltyError = 'Error: $e'; + _loyaltyLoading = false; + }); + } + } + + Future _getLoyaltyStatus() async { + final phone = _loyaltyPhoneController.text.trim(); + if (phone.isEmpty) return; + setState(() { + _loyaltyLoading = true; + _loyaltyError = null; + _loyaltyResult = null; + }); + try { + final response = await _sdk.getLoyaltyStatus(phone); + setState(() { + _loyaltyResult = + 'status: ${response.status}, member=${response.member}, ' + 'level=${response.level?.name ?? '-'}'; + _loyaltyLoading = false; + }); + } catch (e) { + setState(() { + _loyaltyError = 'Error: $e'; + _loyaltyLoading = false; + }); + } + } + Future _demoTrackEvent() async { if (_initState != InitState.initialized) return; try { @@ -507,6 +560,16 @@ class _InitPageState extends State { enabled: _initState == InitState.initialized, onSearch: _searchFull, ), + const SizedBox(height: 12), + _LoyaltyCard( + phoneController: _loyaltyPhoneController, + result: _loyaltyResult, + error: _loyaltyError, + loading: _loyaltyLoading, + enabled: _initState == InitState.initialized, + onJoin: _joinLoyalty, + onStatus: _getLoyaltyStatus, + ), const SizedBox(height: 24), Text('Tracking', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), @@ -803,6 +866,83 @@ class _ProductInfoCard extends StatelessWidget { } } +class _LoyaltyCard extends StatelessWidget { + final TextEditingController phoneController; + final String? result; + final String? error; + final bool loading; + final bool enabled; + final VoidCallback onJoin; + final VoidCallback onStatus; + + const _LoyaltyCard({ + required this.phoneController, + required this.result, + required this.error, + required this.loading, + required this.enabled, + required this.onJoin, + required this.onStatus, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Loyalty', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + TextField( + key: const Key('field_loyalty_phone'), + controller: phoneController, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'Phone', + hintText: 'e.g. 79991234567', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + key: const Key('btn_join_loyalty'), + onPressed: (enabled && !loading) ? onJoin : null, + child: Text(loading ? 'Loading…' : 'Join'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + key: const Key('btn_loyalty_status'), + onPressed: (enabled && !loading) ? onStatus : null, + child: Text(loading ? 'Loading…' : 'Status'), + ), + ), + ], + ), + if (result != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_loyalty_result'), result!), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_loyalty_error'), + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + class _ProductsListCard extends StatelessWidget { final int? total; final String? error; diff --git a/ios/Classes/Rees46FlutterSdkPlugin.swift b/ios/Classes/Rees46FlutterSdkPlugin.swift index 6dc7d3d..576ed58 100644 --- a/ios/Classes/Rees46FlutterSdkPlugin.swift +++ b/ios/Classes/Rees46FlutterSdkPlugin.swift @@ -215,7 +215,7 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { return dict } - private static func _categoryToDict(_ c: Category) -> [String: Any] { + private static func _categoryToDict(_ c: REES46.Category) -> [String: Any] { var dict: [String: Any] = ["id": c.id, "name": c.name] if let parentId = c.parentId { dict["parent_id"] = parentId } if let url = c.url { dict["url"] = url } @@ -286,8 +286,10 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } private static func _productsListResponseToDict(_ r: ProductsListResponse) -> [String: Any] { + // As of REES46 3.24+, ProductsListResponse.products is [Product] (not [ProductInfo]); + // Product carries no categories, which the Dart parser already treats as optional. var dict: [String: Any] = [ - "products": r.products.map { _productInfoToDict($0) }, + "products": r.products.map { _searchProductToDict($0) }, "products_total": r.productsTotal, ] if let pr = r.priceRange { @@ -506,7 +508,7 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { return dict } - private static func _searchCategoryToDict(_ c: Category) -> [String: Any] { + private static func _searchCategoryToDict(_ c: REES46.Category) -> [String: Any] { var dict: [String: Any] = ["id": c.id, "name": c.name] if let url = c.url { dict["url"] = url } if let parent = c.parentId { dict["parent"] = parent } @@ -514,6 +516,78 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { return dict } + func joinLoyalty( + phone: String, + email: String?, + firstName: String?, + lastName: String?, + completion: @escaping (Result) -> Void + ) { + guard let sdk = Rees46FlutterSdkPlugin.sdk else { + completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) + return + } + if phone.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "phone is required", details: nil))) + return + } + sdk.joinLoyalty(phone: phone, email: email, firstName: firstName, lastName: lastName) { result in + switch result { + case .success(let response): + var dict: [String: Any] = ["payload": response.payload] + if let status = response.status { dict["status"] = status } + Self._encodeEnvelope(dict, completion: completion) + case .failure(let err): + completion(.failure(PigeonError(code: "join_loyalty_failed", message: String(describing: err), details: nil))) + } + } + } + + func getLoyaltyStatus(identifier: String, completion: @escaping (Result) -> Void) { + guard let sdk = Rees46FlutterSdkPlugin.sdk else { + completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) + return + } + if identifier.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "identifier is required", details: nil))) + return + } + sdk.getLoyaltyStatus(identifier: identifier) { result in + switch result { + case .success(let response): + var payload: [String: Any] = [:] + if let member = response.member { payload["member"] = member } + if let level = response.level { + var levelDict: [String: Any] = [:] + if let name = level.name { levelDict["name"] = name } + if let code = level.code { levelDict["code"] = code } + if let exp = level.expirationDate { levelDict["expiration_date"] = exp } + payload["level"] = levelDict + } + var dict: [String: Any] = ["payload": payload] + if let status = response.status { dict["status"] = status } + Self._encodeEnvelope(dict, completion: completion) + case .failure(let err): + completion(.failure(PigeonError(code: "loyalty_status_failed", message: String(describing: err), details: nil))) + } + } + } + + /// Serializes a loyalty response envelope `{ "status": ..., "payload": ... }` + /// to a JSON string, mirroring the other JSON-returning host methods. + private static func _encodeEnvelope( + _ dict: [String: Any], + completion: @escaping (Result) -> Void + ) { + guard JSONSerialization.isValidJSONObject(dict), + let data = try? JSONSerialization.data(withJSONObject: dict), + let json = String(data: data, encoding: .utf8) else { + completion(.failure(PigeonError(code: "serialization_failed", message: "Failed to serialize loyalty response", details: nil))) + return + } + completion(.success(json)) + } + func getSid() throws -> String { guard let sdk = Rees46FlutterSdkPlugin.sdk else { throw PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil) @@ -530,38 +604,46 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } - var profileData = ProfileData() - profileData.userEmail = params.email - profileData.userPhone = params.phone - profileData.userLoyaltyId = params.loyaltyId - profileData.firstName = params.firstName - profileData.lastName = params.lastName - profileData.age = params.age.map { Int($0) } - profileData.location = params.location - profileData.advertisingId = params.advertisingId - profileData.fbID = params.fbId - profileData.vkID = params.vkId - profileData.telegramId = params.telegramId - profileData.loyaltyCardLocation = params.loyaltyCardLocation - profileData.loyaltyStatus = params.loyaltyStatus - profileData.loyaltyBonuses = params.loyaltyBonuses.map { Int($0) } - profileData.loyaltyBonusesToNextLevel = params.loyaltyBonusesToNextLevel.map { Int($0) } - profileData.boughtSomething = params.boughtSomething - profileData.userId = params.userId + // As of REES46 3.24+, `setProfileData` takes individual named parameters + // instead of a `ProfileData` value. + var gender: Gender? if let genderStr = params.gender { - profileData.gender = genderStr == "m" ? .male : .female + gender = genderStr == "m" ? .male : .female } + var birthday: Date? if let birthdayStr = params.birthday { let fmt = DateFormatter() fmt.dateFormat = "yyyy-MM-dd" - profileData.birthday = fmt.date(from: birthdayStr) + birthday = fmt.date(from: birthdayStr) } + var customProperties: [String: Any?]? if let json = params.customPropertiesJson, let data = json.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - profileData.customProperties = obj - } - sdk.setProfileData(profileData: profileData) { result in + customProperties = obj + } + sdk.setProfileData( + userEmail: params.email, + userPhone: params.phone, + userLoyaltyId: params.loyaltyId, + birthday: birthday, + age: params.age.map { Int($0) }, + firstName: params.firstName, + lastName: params.lastName, + location: params.location, + gender: gender, + advertisingId: params.advertisingId, + fbID: params.fbId, + vkID: params.vkId, + telegramId: params.telegramId, + loyaltyCardLocation: params.loyaltyCardLocation, + loyaltyStatus: params.loyaltyStatus, + loyaltyBonuses: params.loyaltyBonuses.map { Int($0) }, + loyaltyBonusesToNextLevel: params.loyaltyBonusesToNextLevel.map { Int($0) }, + boughtSomething: params.boughtSomething, + userId: params.userId, + customProperties: customProperties + ) { result in switch result { case .success: completion(.success(())) diff --git a/ios/Classes/pigeon/PersonalizationApi.g.swift b/ios/Classes/pigeon/PersonalizationApi.g.swift index 76222a7..570c819 100644 --- a/ios/Classes/pigeon/PersonalizationApi.g.swift +++ b/ios/Classes/pigeon/PersonalizationApi.g.swift @@ -185,11 +185,14 @@ struct InitConfig: Hashable { } } -/// Wire format for one purchase line (maps to native [PurchaseItemRequest]; quantity not exposed). +/// Wire format for one purchase line (maps to native `PurchaseItemRequest`). +/// [amount] is the canonical line quantity; native's redundant `quantity` alias +/// is intentionally not exposed. /// /// Generated class from Pigeon that represents data sent in messages. struct PurchaseLineItemWire: Hashable { var id: String + /// Number of units of this product in the order (the line quantity). var amount: Int64 var price: Double var lineId: String? = nil @@ -418,6 +421,16 @@ protocol PersonalizationHostApi { /// [paramsJson] is a JSON object string with optional search parameters. /// Dart layer parses the result into [SearchFullResponse]. func searchFull(query: String, paramsJson: String?, completion: @escaping (Result) -> Void) + /// Joins the loyalty program (`loyalty/members/join`) and returns the + /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. + /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. + /// Dart layer parses the result into [LoyaltyJoinResponse]. + func joinLoyalty(phone: String, email: String?, firstName: String?, lastName: String?, completion: @escaping (Result) -> Void) + /// Returns the loyalty membership status (`loyalty/members/status`) as a JSON + /// string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. + /// [identifier] is the member identifier (phone). + /// Dart layer parses the result into [LoyaltyStatusResponse]. + func getLoyaltyStatus(identifier: String, completion: @escaping (Result) -> Void) /// [customJson] and [recommendedSourceJson] are JSON object strings or null. func trackPurchase(orderId: String, orderPrice: Double, items: [PurchaseLineItemWire], deliveryType: String?, deliveryAddress: String?, paymentType: String?, isTaxFree: Bool, promocode: String?, orderCash: Double?, orderBonuses: Double?, orderDelivery: Double?, orderDiscount: Double?, channel: String?, customJson: String?, recommendedSourceJson: String?, stream: String?, segment: String?, completion: @escaping (Result) -> Void) } @@ -660,6 +673,51 @@ class PersonalizationHostApiSetup { } else { searchFullChannel.setMessageHandler(nil) } + /// Joins the loyalty program (`loyalty/members/join`) and returns the + /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. + /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. + /// Dart layer parses the result into [LoyaltyJoinResponse]. + let joinLoyaltyChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + joinLoyaltyChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let phoneArg = args[0] as! String + let emailArg: String? = nilOrValue(args[1]) + let firstNameArg: String? = nilOrValue(args[2]) + let lastNameArg: String? = nilOrValue(args[3]) + api.joinLoyalty(phone: phoneArg, email: emailArg, firstName: firstNameArg, lastName: lastNameArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + joinLoyaltyChannel.setMessageHandler(nil) + } + /// Returns the loyalty membership status (`loyalty/members/status`) as a JSON + /// string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. + /// [identifier] is the member identifier (phone). + /// Dart layer parses the result into [LoyaltyStatusResponse]. + let getLoyaltyStatusChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getLoyaltyStatusChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let identifierArg = args[0] as! String + api.getLoyaltyStatus(identifier: identifierArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getLoyaltyStatusChannel.setMessageHandler(nil) + } /// [customJson] and [recommendedSourceJson] are JSON object strings or null. let trackPurchaseChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { diff --git a/ios/rees46_sdk.podspec b/ios/rees46_sdk.podspec index 9a4a279..9383768 100644 --- a/ios/rees46_sdk.podspec +++ b/ios/rees46_sdk.podspec @@ -14,7 +14,7 @@ Flutter plugin wrapper around REES46 native SDK. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.dependency 'REES46', '3.23.0' + s.dependency 'REES46', '3.28.0' s.platform = :ios, '13.0' # Flutter.framework does not contain a i386 slice. diff --git a/lib/rees46_sdk.dart b/lib/rees46_sdk.dart index 29cb996..5694828 100644 --- a/lib/rees46_sdk.dart +++ b/lib/rees46_sdk.dart @@ -1,4 +1,5 @@ export 'src/personalization_sdk.dart'; +export 'src/loyalty/loyalty_response.dart'; export 'src/profile/profile_params.dart'; export 'src/recommendation/recommendation_params.dart'; export 'src/recommendation/recommendation_response.dart'; diff --git a/lib/src/loyalty/loyalty_response.dart b/lib/src/loyalty/loyalty_response.dart new file mode 100644 index 0000000..abd545d --- /dev/null +++ b/lib/src/loyalty/loyalty_response.dart @@ -0,0 +1,58 @@ +/// Response from [PersonalizationSdk.joinLoyalty] (`loyalty/members/join`). +/// +/// The endpoint returns an envelope `{ "status": ..., "payload": { ... } }`. +/// [payload] is kept as a raw map because its shape differs between success +/// and failure responses. +class LoyaltyJoinResponse { + final String? status; + final Map payload; + + const LoyaltyJoinResponse({this.status, required this.payload}); + + factory LoyaltyJoinResponse.fromJson(Map json) { + return LoyaltyJoinResponse( + status: json['status'] as String?, + payload: + (json['payload'] as Map?)?.cast() ?? + const {}, + ); + } +} + +/// Response from [PersonalizationSdk.getLoyaltyStatus] (`loyalty/members/status`). +/// +/// Envelope: `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. +class LoyaltyStatusResponse { + final String? status; + final bool? member; + final LoyaltyLevel? level; + + const LoyaltyStatusResponse({this.status, this.member, this.level}); + + factory LoyaltyStatusResponse.fromJson(Map json) { + final payload = (json['payload'] as Map?)?.cast(); + final levelJson = (payload?['level'] as Map?)?.cast(); + return LoyaltyStatusResponse( + status: json['status'] as String?, + member: payload?['member'] as bool?, + level: levelJson == null ? null : LoyaltyLevel.fromJson(levelJson), + ); + } +} + +/// Loyalty level returned inside the `loyalty/members/status` payload. +class LoyaltyLevel { + final String? name; + final String? code; + final String? expirationDate; + + const LoyaltyLevel({this.name, this.code, this.expirationDate}); + + factory LoyaltyLevel.fromJson(Map json) { + return LoyaltyLevel( + name: json['name'] as String?, + code: json['code'] as String?, + expirationDate: json['expiration_date'] as String?, + ); + } +} diff --git a/lib/src/personalization_sdk.dart b/lib/src/personalization_sdk.dart index be5924c..8bb5071 100644 --- a/lib/src/personalization_sdk.dart +++ b/lib/src/personalization_sdk.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'pigeon/personalization_api.g.dart' as pigeon; import 'init/sdk_init_handler.dart'; +import 'loyalty/loyalty_response.dart'; import 'profile/profile_params.dart'; import 'push/push_notification_callbacks.dart'; import 'recommendation/recommendation_params.dart'; @@ -111,6 +112,38 @@ class PersonalizationSdk { ); } + /// Joins the loyalty program (native `loyalty/members/join`). + /// + /// The shop is identified by the SDK's configured `shop_id`; [phone] is + /// required, the remaining member fields are optional. + Future joinLoyalty({ + required String phone, + String? email, + String? firstName, + String? lastName, + }) async { + if (phone.isEmpty) { + throw ArgumentError.value(phone, 'phone', 'must be non-empty'); + } + final json = await _api.joinLoyalty(phone, email, firstName, lastName); + return LoyaltyJoinResponse.fromJson( + jsonDecode(json) as Map, + ); + } + + /// Returns the loyalty membership status (native `loyalty/members/status`). + /// + /// [identifier] is the member identifier (phone). + Future getLoyaltyStatus(String identifier) async { + if (identifier.isEmpty) { + throw ArgumentError.value(identifier, 'identifier', 'must be non-empty'); + } + final json = await _api.getLoyaltyStatus(identifier); + return LoyaltyStatusResponse.fromJson( + jsonDecode(json) as Map, + ); + } + Future getRecommendation( String code, { RecommendationParams? params, diff --git a/lib/src/pigeon/personalization_api.g.dart b/lib/src/pigeon/personalization_api.g.dart index 2d2afc9..c8ad2cd 100644 --- a/lib/src/pigeon/personalization_api.g.dart +++ b/lib/src/pigeon/personalization_api.g.dart @@ -123,7 +123,9 @@ class InitConfig { int get hashCode => Object.hashAll(_toList()); } -/// Wire format for one purchase line (maps to native [PurchaseItemRequest]; quantity not exposed). +/// Wire format for one purchase line (maps to native `PurchaseItemRequest`). +/// [amount] is the canonical line quantity; native's redundant `quantity` alias +/// is intentionally not exposed. class PurchaseLineItemWire { PurchaseLineItemWire({ required this.id, @@ -135,6 +137,7 @@ class PurchaseLineItemWire { String id; + /// Number of units of this product in the order (the line quantity). int amount; double price; @@ -782,6 +785,83 @@ class PersonalizationHostApi { } } + /// Joins the loyalty program (`loyalty/members/join`) and returns the + /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. + /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. + /// Dart layer parses the result into [LoyaltyJoinResponse]. + Future joinLoyalty( + String phone, + String? email, + String? firstName, + String? lastName, + ) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [phone, email, firstName, lastName], + ); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as String?)!; + } + } + + /// Returns the loyalty membership status (`loyalty/members/status`) as a JSON + /// string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. + /// [identifier] is the member identifier (phone). + /// Dart layer parses the result into [LoyaltyStatusResponse]. + Future getLoyaltyStatus(String identifier) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [identifier], + ); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as String?)!; + } + } + /// [customJson] and [recommendedSourceJson] are JSON object strings or null. Future trackPurchase( String orderId, diff --git a/pigeons/personalization_api.dart b/pigeons/personalization_api.dart index 2f71f42..4ffd05b 100644 --- a/pigeons/personalization_api.dart +++ b/pigeons/personalization_api.dart @@ -174,6 +174,25 @@ abstract class PersonalizationHostApi { @async String searchFull(String query, String? paramsJson); + /// Joins the loyalty program (`loyalty/members/join`) and returns the + /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. + /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. + /// Dart layer parses the result into [LoyaltyJoinResponse]. + @async + String joinLoyalty( + String phone, + String? email, + String? firstName, + String? lastName, + ); + + /// Returns the loyalty membership status (`loyalty/members/status`) as a JSON + /// string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. + /// [identifier] is the member identifier (phone). + /// Dart layer parses the result into [LoyaltyStatusResponse]. + @async + String getLoyaltyStatus(String identifier); + /// [customJson] and [recommendedSourceJson] are JSON object strings or null. @async void trackPurchase( diff --git a/test/loyalty_test.dart b/test/loyalty_test.dart new file mode 100644 index 0000000..2296ff7 --- /dev/null +++ b/test/loyalty_test.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rees46_sdk/rees46_sdk.dart'; +import 'package:rees46_sdk/src/pigeon/personalization_api.g.dart' as pigeon; + +const _joinChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty'; +const _statusChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final messageCodec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + ByteData reply(String value) => messageCodec.encodeMessage([value])!; + + void stub(String channel, String json) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (_) async => reply(json)); + } + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_joinChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_statusChannel, null); + }); + + // ------------------------------------------------------------------------- + // joinLoyalty + // ------------------------------------------------------------------------- + group('joinLoyalty', () { + test('parses status and raw payload', () async { + stub( + _joinChannel, + jsonEncode({ + 'status': 'success', + 'payload': {'member_id': 42, 'created': true}, + }), + ); + + final r = await PersonalizationSdk().joinLoyalty(phone: '79991234567'); + expect(r, isA()); + expect(r.status, equals('success')); + expect(r.payload['member_id'], equals(42)); + expect(r.payload['created'], isTrue); + }); + + test('missing payload defaults to empty map', () async { + stub(_joinChannel, jsonEncode({'status': 'success'})); + + final r = await PersonalizationSdk().joinLoyalty(phone: '79991234567'); + expect(r.status, equals('success')); + expect(r.payload, isEmpty); + }); + + test('empty phone throws ArgumentError (no native call)', () async { + expect( + () => PersonalizationSdk().joinLoyalty(phone: ''), + throwsA(isA()), + ); + }); + }); + + // ------------------------------------------------------------------------- + // getLoyaltyStatus + // ------------------------------------------------------------------------- + group('getLoyaltyStatus', () { + test('parses member and level', () async { + stub( + _statusChannel, + jsonEncode({ + 'status': 'success', + 'payload': { + 'member': true, + 'level': { + 'name': 'Gold', + 'code': 'gold', + 'expiration_date': '2026-12-31', + }, + }, + }), + ); + + final r = await PersonalizationSdk().getLoyaltyStatus('79991234567'); + expect(r, isA()); + expect(r.status, equals('success')); + expect(r.member, isTrue); + expect(r.level?.name, equals('Gold')); + expect(r.level?.code, equals('gold')); + expect(r.level?.expirationDate, equals('2026-12-31')); + }); + + test('absent level parses to null, non-member', () async { + stub( + _statusChannel, + jsonEncode({ + 'status': 'success', + 'payload': {'member': false}, + }), + ); + + final r = await PersonalizationSdk().getLoyaltyStatus('79991234567'); + expect(r.member, isFalse); + expect(r.level, isNull); + }); + + test('missing payload parses to nulls', () async { + stub(_statusChannel, jsonEncode({'status': 'success'})); + + final r = await PersonalizationSdk().getLoyaltyStatus('79991234567'); + expect(r.member, isNull); + expect(r.level, isNull); + }); + + test('empty identifier throws ArgumentError (no native call)', () async { + expect( + () => PersonalizationSdk().getLoyaltyStatus(''), + throwsA(isA()), + ); + }); + }); +}