From 6c673c540ac678375176c5d585354c98b26e2a8b Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Thu, 4 Jun 2026 18:43:14 +0400 Subject: [PATCH] DEV-648: initial Flutter SDK source + repo sync workflow --- .github/workflows/deploy.yaml | 8 +- .github/workflows/sync.yaml | 40 + .gitignore | 33 + .metadata | 33 + CHANGELOG.md | 3 + LICENSE | 1 + README.md | 60 + analysis_options.yaml | 4 + android/.gitignore | 9 + android/build.gradle.kts | 95 ++ android/settings.gradle | 1 + android/settings.gradle.kts | 1 + android/src/main/AndroidManifest.xml | 3 + .../FlutterTrackingBridge.kt | 444 +++++++ .../Rees46FlutterSdkPlugin.kt | 631 ++++++++++ .../pigeon/PersonalizationApi.g.kt | 710 +++++++++++ .../Rees46FlutterSdkPluginTest.kt | 20 + example/.gitignore | 45 + example/README.md | 17 + example/analysis_options.yaml | 28 + example/android/.gitignore | 14 + example/android/app/build.gradle.kts | 58 + example/android/app/google-services.json | 238 ++++ .../MainActivityTest.java | 32 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 + .../MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + example/android/build.gradle.kts | 25 + example/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + example/android/settings.gradle.kts | 27 + example/integration_test/init_flow_test.dart | 47 + .../integration_test/patrol_smoke_test.dart | 29 + .../product_info_sdk_test.dart | 77 ++ .../products_list_sdk_test.dart | 63 + .../integration_test/profile_sdk_test.dart | 108 ++ example/integration_test/push_token_test.dart | 32 + .../recommendation_sdk_test.dart | 103 ++ .../search_blank_sdk_test.dart | 70 ++ .../search_instant_sdk_test.dart | 98 ++ example/integration_test/search_sdk_test.dart | 103 ++ example/integration_test/test_bundle.dart | 89 ++ example/integration_test/test_config.dart | 19 + example/ios/.gitignore | 34 + example/ios/Flutter/AppFrameworkInfo.plist | 24 + example/ios/Flutter/Debug.xcconfig | 2 + example/ios/Flutter/Release.xcconfig | 2 + example/ios/Podfile | 47 + example/ios/Podfile.lock | 46 + example/ios/Runner.xcodeproj/project.pbxproj | 889 ++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 111 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + example/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 ++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + example/ios/Runner/Base.lproj/Main.storyboard | 26 + example/ios/Runner/GoogleService-Info.plist | 30 + example/ios/Runner/Info.plist | 70 ++ example/ios/Runner/Runner-Bridging-Header.h | 1 + example/ios/Runner/SceneDelegate.swift | 6 + example/ios/RunnerTests/RunnerTests.swift | 7 + example/ios/RunnerUITests/RunnerUITests.m | 5 + example/lib/main.dart | 1089 +++++++++++++++++ example/plugin_integration_test.dart | 24 + example/pubspec.lock | 371 ++++++ example/pubspec.yaml | 94 ++ example/test/widget_test.dart | 17 + ios/.gitignore | 38 + ios/Assets/.gitkeep | 0 ios/Classes/Rees46FlutterSdkPlugin.swift | 710 +++++++++++ ios/Classes/pigeon/PersonalizationApi.g.swift | 769 ++++++++++++ ios/Resources/PrivacyInfo.xcprivacy | 14 + ios/personalization_flutter_sdk.podspec | 27 + lib/personaclick_flutter_sdk.dart | 9 + lib/personalization_flutter_sdk.dart | 10 + lib/rees46_flutter_sdk_method_channel.dart | 1 + ...rees46_flutter_sdk_platform_interface.dart | 1 + lib/src/init/sdk_init_handler.dart | 53 + lib/src/method_channel.dart | 1 + lib/src/personalization_sdk.dart | 209 ++++ lib/src/pigeon/personalization_api.g.dart | 972 +++++++++++++++ lib/src/platform_interface.dart | 1 + lib/src/products/products_list_params.dart | 37 + lib/src/products/products_list_response.dart | 129 ++ lib/src/profile/profile_params.dart | 84 ++ lib/src/push/push_notification_callbacks.dart | 33 + .../recommendation/recommendation_params.dart | 40 + .../recommendation_response.dart | 147 +++ lib/src/sdk_init_config.dart | 28 + lib/src/search/search_params.dart | 84 ++ lib/src/search/search_response.dart | 258 ++++ lib/src/tracking/purchase_line_item.dart | 25 + pigeons/personalization_api.dart | 204 +++ pubspec.yaml | 73 ++ test/personalization_sdk_test.dart | 276 +++++ test/product_info_test.dart | 178 +++ test/products_list_test.dart | 223 ++++ test/profile_test.dart | 286 +++++ test/push_notification_callbacks_test.dart | 123 ++ test/recommendation_test.dart | 245 ++++ ...ees46_flutter_sdk_method_channel_test.dart | 8 + test/rees46_flutter_sdk_test.dart | 8 + test/sdk_init_handler_test.dart | 125 ++ test/search_blank_test.dart | 144 +++ test/search_instant_test.dart | 205 ++++ test/search_test.dart | 293 +++++ tool/apply_brand.dart | 233 ++++ 142 files changed, 12999 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sync.yaml create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/build.gradle.kts create mode 100644 android/settings.gradle create mode 100644 android/settings.gradle.kts create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt create mode 100644 android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt create mode 100644 android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt create mode 100644 android/src/test/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPluginTest.kt create mode 100644 example/.gitignore create mode 100644 example/README.md create mode 100644 example/analysis_options.yaml create mode 100644 example/android/.gitignore create mode 100644 example/android/app/build.gradle.kts create mode 100644 example/android/app/google-services.json create mode 100644 example/android/app/src/androidTest/java/com/rees46/rees46_flutter_sdk_example/MainActivityTest.java create mode 100644 example/android/app/src/debug/AndroidManifest.xml create mode 100644 example/android/app/src/main/AndroidManifest.xml create mode 100644 example/android/app/src/main/kotlin/com/rees46/rees46_flutter_sdk_example/MainActivity.kt create mode 100644 example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/values-night/styles.xml create mode 100644 example/android/app/src/main/res/values/styles.xml create mode 100644 example/android/app/src/profile/AndroidManifest.xml create mode 100644 example/android/build.gradle.kts create mode 100644 example/android/gradle.properties create mode 100644 example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 example/android/settings.gradle.kts create mode 100644 example/integration_test/init_flow_test.dart create mode 100644 example/integration_test/patrol_smoke_test.dart create mode 100644 example/integration_test/product_info_sdk_test.dart create mode 100644 example/integration_test/products_list_sdk_test.dart create mode 100644 example/integration_test/profile_sdk_test.dart create mode 100644 example/integration_test/push_token_test.dart create mode 100644 example/integration_test/recommendation_sdk_test.dart create mode 100644 example/integration_test/search_blank_sdk_test.dart create mode 100644 example/integration_test/search_instant_sdk_test.dart create mode 100644 example/integration_test/search_sdk_test.dart create mode 100644 example/integration_test/test_bundle.dart create mode 100644 example/integration_test/test_config.dart create mode 100644 example/ios/.gitignore create mode 100644 example/ios/Flutter/AppFrameworkInfo.plist create mode 100644 example/ios/Flutter/Debug.xcconfig create mode 100644 example/ios/Flutter/Release.xcconfig create mode 100644 example/ios/Podfile create mode 100644 example/ios/Podfile.lock create mode 100644 example/ios/Runner.xcodeproj/project.pbxproj create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 example/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 example/ios/Runner/AppDelegate.swift create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 example/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 example/ios/Runner/Base.lproj/Main.storyboard create mode 100644 example/ios/Runner/GoogleService-Info.plist create mode 100644 example/ios/Runner/Info.plist create mode 100644 example/ios/Runner/Runner-Bridging-Header.h create mode 100644 example/ios/Runner/SceneDelegate.swift create mode 100644 example/ios/RunnerTests/RunnerTests.swift create mode 100644 example/ios/RunnerUITests/RunnerUITests.m create mode 100644 example/lib/main.dart create mode 100644 example/plugin_integration_test.dart create mode 100644 example/pubspec.lock create mode 100644 example/pubspec.yaml create mode 100644 example/test/widget_test.dart create mode 100644 ios/.gitignore create mode 100644 ios/Assets/.gitkeep create mode 100644 ios/Classes/Rees46FlutterSdkPlugin.swift create mode 100644 ios/Classes/pigeon/PersonalizationApi.g.swift create mode 100644 ios/Resources/PrivacyInfo.xcprivacy create mode 100644 ios/personalization_flutter_sdk.podspec create mode 100644 lib/personaclick_flutter_sdk.dart create mode 100644 lib/personalization_flutter_sdk.dart create mode 100644 lib/rees46_flutter_sdk_method_channel.dart create mode 100644 lib/rees46_flutter_sdk_platform_interface.dart create mode 100644 lib/src/init/sdk_init_handler.dart create mode 100644 lib/src/method_channel.dart create mode 100644 lib/src/personalization_sdk.dart create mode 100644 lib/src/pigeon/personalization_api.g.dart create mode 100644 lib/src/platform_interface.dart create mode 100644 lib/src/products/products_list_params.dart create mode 100644 lib/src/products/products_list_response.dart create mode 100644 lib/src/profile/profile_params.dart create mode 100644 lib/src/push/push_notification_callbacks.dart create mode 100644 lib/src/recommendation/recommendation_params.dart create mode 100644 lib/src/recommendation/recommendation_response.dart create mode 100644 lib/src/sdk_init_config.dart create mode 100644 lib/src/search/search_params.dart create mode 100644 lib/src/search/search_response.dart create mode 100644 lib/src/tracking/purchase_line_item.dart create mode 100644 pigeons/personalization_api.dart create mode 100644 pubspec.yaml create mode 100644 test/personalization_sdk_test.dart create mode 100644 test/product_info_test.dart create mode 100644 test/products_list_test.dart create mode 100644 test/profile_test.dart create mode 100644 test/push_notification_callbacks_test.dart create mode 100644 test/recommendation_test.dart create mode 100644 test/rees46_flutter_sdk_method_channel_test.dart create mode 100644 test/rees46_flutter_sdk_test.dart create mode 100644 test/sdk_init_handler_test.dart create mode 100644 test/search_blank_test.dart create mode 100644 test/search_instant_test.dart create mode 100644 test/search_test.dart create mode 100644 tool/apply_brand.dart diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index fe32683..ba14347 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -35,8 +35,14 @@ jobs: echo "should_publish=true" >> $GITHUB_OUTPUT fi + # Publishing is gated behind the ENABLE_PUBLISH repo variable so the + # Deploy workflow still runs and succeeds (which is what sync.yaml's + # workflow_run trigger needs) while we test repo sync + branding. + # To start publishing for real: set repo variable ENABLE_PUBLISH=true. - name: Publish to pub.dev - if: steps.version_check.outputs.should_publish == 'true' + if: >- + vars.ENABLE_PUBLISH == 'true' && + steps.version_check.outputs.should_publish == 'true' run: | # Создаём файл с credentials (берём из секретов) echo "${{ secrets.PUB_DEV_CREDENTIALS }}" > ~/.pub-cache/credentials.json diff --git a/.github/workflows/sync.yaml b/.github/workflows/sync.yaml new file mode 100644 index 0000000..b9c5033 --- /dev/null +++ b/.github/workflows/sync.yaml @@ -0,0 +1,40 @@ +name: Repository synchronization + +on: + workflow_run: + workflows: + - Deploy + types: + - completed + workflow_dispatch: + +jobs: + prepare: + if: | + (github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success') && + !contains(github.repository_owner, 'personaclick') + runs-on: ubuntu-latest + outputs: + replacements: ${{ steps.getReplacementsStep.outputs.replacements }} + steps: + - uses: rees46/workflow/.github/actions/sync/read-replacements@master + id: getReplacementsStep + with: + appId: ${{ vars.PUBLIVERSIONER_ID }} + appSecret: ${{ secrets.PUBLIVERSIONER_SECRET }} + replacementsRepo: rees46/workflow + repositoryOwner: rees46 + replacementsPath: github/repo-sync-replacements/flutter-sdk.yml + + repoSync: + needs: prepare + uses: rees46/workflow/.github/workflows/reusable-repo-sync.yml@master + secrets: + appSecret: ${{ secrets.PERSONACLICK_COURIER_SECRET }} + with: + appId: ${{ vars.PERSONACLICK_COURIER_ID }} + replacements: ${{ needs.prepare.outputs.replacements }} + repositoryOwner: personaclick + targetRepository: personaclick/flutter-sdk + reviewerUsername: ${{ vars.REVIEWER_USERNAME }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..0798d44 --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: android + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: ios + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..258531a --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# rees46_flutter_sdk + +Flutter plugin wrapper around REES46 native SDKs (Android/iOS). + +## Getting Started + +### Install + +Add dependency in your app: + +```yaml +dependencies: + personalization_flutter_sdk: ^0.0.1 +``` + +### Initialize + +```dart +import 'package:personaclick_flutter_sdk/personaclick_flutter_sdk.dart'; + +final sdk = Rees46FlutterSdk(); + +await sdk.initialize( + const Rees46InitConfig( + shopId: 'YOUR_SHOP_ID', + apiDomain: 'api.rees46.ru', + // stream defaults to 'ios' on iOS and 'android' on Android if omitted. + stream: 'ios', + enableLogs: false, + autoSendPushToken: true, + sendAdvertisingId: false, + enableAutoPopupPresentation: true, + needReInitialization: false, + ), +); +``` + +### API structure (core + wrappers) + +- **Core (private-ish)**: `PersonalizationSdk` and `SdkInitConfig` live in `lib/src/`. +- **REES46 wrapper**: `Rees46FlutterSdk` + `Rees46InitConfig` in `lib/rees46_flutter_sdk.dart`. +- **PersonaClick wrapper**: `PersonaclickFlutterSdk` + `PersonaclickInitConfig` in `lib/personaclick_flutter_sdk.dart` (primary entrypoint in PersonaClick repo). + +### Run demo app + +```bash +cd example +fvm flutter run +``` + +### Notes + +- **Android**: uses Maven dependency `com.rees46:rees46-sdk:2.28.0` and calls `SDK.initialize(...)`. Some iOS-only init flags are accepted by Dart API but ignored on Android. +- **iOS**: uses CocoaPods dependency `REES46 (3.23.0)` and calls `createPersonalizationSDK(...)`. +- **Pushes**: + - **Android**: when `autoSendPushToken=true`, the native SDK fetches the FCM token via `FirebaseMessaging.getInstance().token` during initialization and sends it. + - **iOS**: when `autoSendPushToken=true`, the native SDK requests notification permission and registers for remote notifications. The Flutter plugin also forwards `didRegisterForRemoteNotificationsWithDeviceToken` and `didReceiveRemoteNotification` AppDelegate callbacks to the native SDK. + +For Flutter plugin development basics, see Flutter docs: [develop plugins](https://flutter.dev/to/develop-plugins). + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dd4091d --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,95 @@ +group = "com.rees46.rees46_flutter_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") + } + + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") + } +} + +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + namespace = "com.rees46.rees46_flutter_sdk" + + compileSdk = 36 + + flavorDimensions += "brand" + + productFlavors { + create("rees46") { + dimension = "brand" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +dependencies { + // REES46 Android SDK (Maven Central). + // + // Note: the 2.x line in Maven Central uses versions like 2.6.0 (not 2.28.0). + val rees46AndroidSdkVersion = "2.6.0" + add( + "rees46Implementation", + "com.rees46:rees46-sdk:$rees46AndroidSdkVersion", + ) + + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..afbb760 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'rees46_flutter_sdk' diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..afbb760 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = 'rees46_flutter_sdk' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c312d8b --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt new file mode 100644 index 0000000..893831c --- /dev/null +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt @@ -0,0 +1,444 @@ +package com.rees46.rees46_flutter_sdk + +import com.personalization.SDK +import com.personalization.api.OnApiCallbackListener +import com.personalization.sdk.data.models.params.UserBasicParams +import com.rees46.rees46_flutter_sdk.pigeon.FlutterError +import com.rees46.rees46_flutter_sdk.pigeon.PurchaseLineItemWire +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Builds tracking JSON and posts via [SDK.sendAsync] for published `rees46-sdk` artifacts that do not + * expose `SDK.trackEvent` / `SDK.trackPurchase` (wire format aligned with personalization-sdk + * `TrackEventManagerImpl` and `PurchaseTrackingJsonBuilder`). + */ +internal object FlutterTrackingBridge { + private const val CUSTOM_PUSH_PATH = "push/custom" + private const val PURCHASE_PUSH_PATH = "push" + + private const val TRACK_EVENT_CLIENT_ERROR_CODE = -1 + private const val TRACK_PURCHASE_CLIENT_ERROR_CODE = -2 + + private const val KEY_EVENT = "event" + private const val KEY_TIME = "time" + private const val KEY_CATEGORY = "category" + private const val KEY_LABEL = "label" + private const val KEY_VALUE = "value" + private const val KEY_SOURCE = "source" + private const val KEY_PAYLOAD = "payload" + private const val KEY_FROM = "from" + private const val KEY_CODE = "code" + private const val KEY_STREAM = "stream" + + private val RESERVED_CUSTOM_EVENT_KEYS: Set = + buildSet { + add(UserBasicParams.SHOP_ID) + add(UserBasicParams.DID) + add(UserBasicParams.SEANCE) + add(UserBasicParams.SID) + add(UserBasicParams.SEGMENT) + add(KEY_STREAM) + add(KEY_EVENT) + add(KEY_TIME) + add(KEY_CATEGORY) + add(KEY_LABEL) + add(KEY_VALUE) + add(KEY_SOURCE) + add(KEY_PAYLOAD) + add(KEY_FROM) + add(KEY_CODE) + } + + private object PurchaseWireKeys { + const val EVENT = "event" + const val PURCHASE_EVENT_VALUE = "purchase" + const val ITEMS = "items" + const val ID = "id" + const val AMOUNT = "amount" + const val PRICE = "price" + const val LINE_ID = "line_id" + const val FASHION_SIZE = "fashion_size" + const val ORDER_ID = "order_id" + const val ORDER_PRICE = "order_price" + const val DELIVERY_TYPE = "delivery_type" + const val DELIVERY_ADDRESS = "delivery_address" + const val PAYMENT_TYPE = "payment_type" + const val TAX_FREE = "tax_free" + const val PROMOCODE = "promocode" + const val ORDER_CASH = "order_cash" + const val ORDER_BONUSES = "order_bonuses" + const val ORDER_DELIVERY = "order_delivery" + const val ORDER_DISCOUNT = "order_discount" + const val CHANNEL = "channel" + const val CUSTOM = "custom" + const val RECOMMENDED_SOURCE = "recommended_source" + const val RECOMMENDED_BY = "recommended_by" + const val RECOMMENDED_CODE = "recommended_code" + } + + private val RESERVED_PURCHASE_CUSTOM_KEYS: Set = + RESERVED_CUSTOM_EVENT_KEYS + + setOf( + PurchaseWireKeys.EVENT, + PurchaseWireKeys.ITEMS, + PurchaseWireKeys.ORDER_ID, + PurchaseWireKeys.ORDER_PRICE, + PurchaseWireKeys.DELIVERY_TYPE, + PurchaseWireKeys.DELIVERY_ADDRESS, + PurchaseWireKeys.PAYMENT_TYPE, + PurchaseWireKeys.TAX_FREE, + PurchaseWireKeys.PROMOCODE, + PurchaseWireKeys.ORDER_CASH, + PurchaseWireKeys.ORDER_BONUSES, + PurchaseWireKeys.ORDER_DELIVERY, + PurchaseWireKeys.ORDER_DISCOUNT, + PurchaseWireKeys.CHANNEL, + PurchaseWireKeys.CUSTOM, + PurchaseWireKeys.RECOMMENDED_SOURCE, + PurchaseWireKeys.RECOMMENDED_BY, + PurchaseWireKeys.RECOMMENDED_CODE, + ) + + fun postTrackEvent( + event: String, + time: Long?, + category: String?, + label: String?, + value: Long?, + customFields: Map?, + callback: (Result) -> Unit, + ) { + val effectiveCustom = effectiveCustomFields(customFields) + validateNoReservedKeyCollisions(effectiveCustom, RESERVED_CUSTOM_EVENT_KEYS)?.let { msg -> + callback( + Result.failure( + FlutterError( + "track_event_failed", + msg, + mapOf("code" to TRACK_EVENT_CLIENT_ERROR_CODE), + ), + ), + ) + return + } + + val body = JSONObject() + try { + body.put(KEY_EVENT, event) + time?.let { body.put(KEY_TIME, longToJsonInt(it)) } + category?.let { body.put(KEY_CATEGORY, it) } + label?.let { body.put(KEY_LABEL, it) } + value?.let { body.put(KEY_VALUE, longToJsonInt(it)) } + if (effectiveCustom.isNotEmpty()) { + val payload = JSONObject() + for ((key, fieldValue) in effectiveCustom) { + putJsonValue(body, key, fieldValue) + putJsonValue(payload, key, fieldValue) + } + body.put(KEY_PAYLOAD, payload) + } + } catch (e: JSONException) { + callback( + Result.failure( + FlutterError( + "track_event_failed", + "trackEvent: failed to build JSON: ${e.message}", + null, + ), + ), + ) + return + } + + @Suppress("DEPRECATION") + SDK.instance.sendAsync( + CUSTOM_PUSH_PATH, + body, + object : OnApiCallbackListener() { + override fun onSuccess(response: JSONObject?) { + callback(Result.success(Unit)) + } + + override fun onError(code: Int, msg: String?) { + val message = listOfNotNull(code.toString(), msg).joinToString(": ") + callback(Result.failure(FlutterError("track_event_failed", message, null))) + } + }, + ) + } + + fun postTrackPurchase( + 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?, + custom: Map?, + recommendedSource: JSONObject?, + stream: String?, + segment: String?, + callback: (Result) -> Unit, + ) { + val buildResult = buildPurchaseJsonOrError( + orderId = orderId, + orderPrice = orderPrice, + items = items, + deliveryType = deliveryType, + deliveryAddress = deliveryAddress, + paymentType = paymentType, + isTaxFree = isTaxFree, + promocode = promocode, + orderCash = orderCash, + orderBonuses = orderBonuses, + orderDelivery = orderDelivery, + orderDiscount = orderDiscount, + channel = channel, + custom = custom, + recommendedSource = recommendedSource, + stream = stream, + segment = segment, + ) + if (buildResult.isFailure) { + callback( + Result.failure( + FlutterError( + "track_purchase_failed", + buildResult.exceptionOrNull()?.message ?: "validation failed", + mapOf("code" to TRACK_PURCHASE_CLIENT_ERROR_CODE), + ), + ), + ) + return + } + val body = buildResult.getOrNull()!! + + @Suppress("DEPRECATION") + SDK.instance.sendAsync( + PURCHASE_PUSH_PATH, + body, + object : OnApiCallbackListener() { + override fun onSuccess(response: JSONObject?) { + callback(Result.success(Unit)) + } + + override fun onError(code: Int, msg: String?) { + val message = listOfNotNull(code.toString(), msg).joinToString(": ") + callback(Result.failure(FlutterError("track_purchase_failed", message, null))) + } + }, + ) + } + + private fun buildPurchaseJsonOrError( + 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?, + custom: Map?, + recommendedSource: JSONObject?, + stream: String?, + segment: String?, + ): Result { + if (orderId.isBlank()) { + return Result.failure(IllegalArgumentException("trackPurchase: orderId must be non-empty")) + } + if (items.isEmpty()) { + return Result.failure(IllegalArgumentException("trackPurchase: items must not be empty")) + } + for (item in items) { + if (item.id.isBlank()) { + return Result.failure(IllegalArgumentException("trackPurchase: each item.id must be non-empty")) + } + if (item.amount <= 0) { + return Result.failure(IllegalArgumentException("trackPurchase: each item.amount must be > 0")) + } + if (!item.price.isFinite()) { + return Result.failure(IllegalArgumentException("trackPurchase: each item.price must be a finite number")) + } + } + if (!orderPrice.isFinite()) { + return Result.failure(IllegalArgumentException("trackPurchase: orderPrice must be a finite number")) + } + + val effectiveCustom = effectiveCustomFields(custom) + if (effectiveCustom.isNotEmpty()) { + val collisions = effectiveCustom.keys.intersect(RESERVED_PURCHASE_CUSTOM_KEYS) + if (collisions.isNotEmpty()) { + return Result.failure( + IllegalArgumentException( + "trackPurchase: custom contains reserved keys: ${collisions.toSortedSet().joinToString(", ")}", + ), + ) + } + } + + return try { + Result.success( + buildPurchaseJson( + orderId = orderId, + orderPrice = orderPrice, + items = items, + deliveryType = deliveryType, + deliveryAddress = deliveryAddress, + paymentType = paymentType, + isTaxFree = isTaxFree, + promocode = promocode, + orderCash = orderCash, + orderBonuses = orderBonuses, + orderDelivery = orderDelivery, + orderDiscount = orderDiscount, + channel = channel, + effectiveCustom = effectiveCustom, + recommendedSource = recommendedSource, + stream = stream, + segment = segment, + ), + ) + } catch (e: JSONException) { + Result.failure(IllegalArgumentException("trackPurchase: failed to build JSON: ${e.message}", e)) + } + } + + private fun buildPurchaseJson( + 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?, + effectiveCustom: Map, + recommendedSource: JSONObject?, + stream: String?, + segment: String?, + ): JSONObject { + val root = JSONObject() + root.put(PurchaseWireKeys.EVENT, PurchaseWireKeys.PURCHASE_EVENT_VALUE) + root.put(PurchaseWireKeys.ORDER_ID, orderId) + root.put(PurchaseWireKeys.ORDER_PRICE, orderPrice) + + val itemsArray = JSONArray() + for (item in items) { + val row = JSONObject() + row.put(PurchaseWireKeys.ID, item.id) + row.put(PurchaseWireKeys.AMOUNT, item.amount) + row.put(PurchaseWireKeys.PRICE, item.price) + item.lineId?.takeIf { it.isNotBlank() }?.let { row.put(PurchaseWireKeys.LINE_ID, it) } + item.fashionSize?.takeIf { it.isNotBlank() }?.let { + row.put(PurchaseWireKeys.FASHION_SIZE, it) + } + itemsArray.put(row) + } + root.put(PurchaseWireKeys.ITEMS, itemsArray) + + deliveryType?.takeIf { it.isNotBlank() }?.let { + root.put(PurchaseWireKeys.DELIVERY_TYPE, it) + } + deliveryAddress?.takeIf { it.isNotBlank() }?.let { + root.put(PurchaseWireKeys.DELIVERY_ADDRESS, it) + } + paymentType?.takeIf { it.isNotBlank() }?.let { + root.put(PurchaseWireKeys.PAYMENT_TYPE, it) + } + if (isTaxFree) { + root.put(PurchaseWireKeys.TAX_FREE, true) + } + promocode?.takeIf { it.isNotBlank() }?.let { + root.put(PurchaseWireKeys.PROMOCODE, it) + } + orderCash?.let { root.put(PurchaseWireKeys.ORDER_CASH, it) } + orderBonuses?.let { root.put(PurchaseWireKeys.ORDER_BONUSES, it) } + orderDelivery?.let { root.put(PurchaseWireKeys.ORDER_DELIVERY, it) } + orderDiscount?.let { root.put(PurchaseWireKeys.ORDER_DISCOUNT, it) } + channel?.takeIf { it.isNotBlank() }?.let { + root.put(PurchaseWireKeys.CHANNEL, it) + } + + if (effectiveCustom.isNotEmpty()) { + val customJson = JSONObject() + for ((key, value) in effectiveCustom) { + putJsonValue(customJson, key, value) + } + root.put(PurchaseWireKeys.CUSTOM, customJson) + } + + recommendedSource?.let { root.put(PurchaseWireKeys.RECOMMENDED_SOURCE, it) } + + stream?.takeIf { it.isNotBlank() }?.let { + root.put(UserBasicParams.STREAM, it) + } + segment?.takeIf { it.isNotBlank() }?.let { + root.put(UserBasicParams.SEGMENT, it) + } + + return root + } + + private fun effectiveCustomFields(map: Map?): Map { + if (map.isNullOrEmpty()) return emptyMap() + val out = LinkedHashMap() + for ((key, value) in map) { + if (key.isBlank() || value == null) continue + out[key] = value + } + return out + } + + private fun validateNoReservedKeyCollisions( + customFields: Map, + reserved: Set, + ): String? { + if (customFields.isEmpty()) return null + val collisions = customFields.keys.intersect(reserved) + if (collisions.isEmpty()) return null + val sorted = collisions.toSortedSet().joinToString(", ") + return "trackEvent: customFields contains reserved keys: $sorted" + } + + @Throws(JSONException::class) + private fun putJsonValue(target: JSONObject, key: String, value: Any) { + when (value) { + is String -> target.put(key, value) + is Int -> target.put(key, value) + is Long -> target.put(key, value) + is Double -> target.put(key, value) + is Float -> target.put(key, value.toDouble()) + is Boolean -> target.put(key, value) + is JSONObject -> target.put(key, value) + is JSONArray -> target.put(key, value) + else -> target.put(key, value.toString()) + } + } + + private fun longToJsonInt(value: Long): Int = + when { + value > Int.MAX_VALUE -> Int.MAX_VALUE + value < Int.MIN_VALUE -> Int.MIN_VALUE + else -> value.toInt() + } +} 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 new file mode 100644 index 0000000..9a06bfd --- /dev/null +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt @@ -0,0 +1,631 @@ +package com.rees46.rees46_flutter_sdk + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.os.SystemClock +import com.rees46.rees46_flutter_sdk.pigeon.FlutterError +import com.rees46.rees46_flutter_sdk.pigeon.InitConfig +import com.rees46.rees46_flutter_sdk.pigeon.PersonalizationFlutterApi +import com.rees46.rees46_flutter_sdk.pigeon.PersonalizationHostApi +import com.rees46.rees46_flutter_sdk.pigeon.ProfileParamsWire +import com.rees46.rees46_flutter_sdk.pigeon.PurchaseLineItemWire +import com.google.gson.Gson +import com.personalization.Params +import com.personalization.SDK +import com.personalization.api.OnApiCallbackListener +import com.personalization.api.params.ProfileParams +import com.personalization.api.params.SearchParams as NativeSearchParams +import com.personalization.features.notification.presentation.helpers.NotificationImageHelper +import com.personalization.sdk.data.models.dto.notification.NotificationData +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.PluginRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject + +/** Rees46FlutterSdkPlugin */ +class Rees46FlutterSdkPlugin : + FlutterPlugin, + ActivityAware, + PersonalizationHostApi { + private lateinit var applicationContext: Context + private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) + private var flutterApi: PersonalizationFlutterApi? = null + private var activityBinding: ActivityPluginBinding? = null + + private val onNewIntentListener = + object : PluginRegistry.NewIntentListener { + override fun onNewIntent(intent: Intent): Boolean { + this@Rees46FlutterSdkPlugin.handleNotificationLaunchIntent(intent) + return false + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = flutterPluginBinding.applicationContext + PersonalizationHostApi.setUp(flutterPluginBinding.binaryMessenger, this) + flutterApi = PersonalizationFlutterApi(flutterPluginBinding.binaryMessenger) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + PersonalizationHostApi.setUp(binding.binaryMessenger, null) + flutterApi = null + coroutineScope.cancel() + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + bindActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + unbindActivity() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + bindActivity(binding) + } + + override fun onDetachedFromActivity() { + unbindActivity() + } + + private fun bindActivity(binding: ActivityPluginBinding) { + unbindActivity() + activityBinding = binding + binding.addOnNewIntentListener(onNewIntentListener) + handleNotificationLaunchIntent(binding.activity.intent) + } + + private fun unbindActivity() { + activityBinding?.removeOnNewIntentListener(onNewIntentListener) + activityBinding = null + } + + override fun getPlatformVersion(): String = "Android ${android.os.Build.VERSION.RELEASE}" + + override fun getStoredPushToken(): String? { + val prefs: SharedPreferences = + applicationContext.getSharedPreferences(DEFAULT_STORAGE_KEY, Context.MODE_PRIVATE) + return prefs.getString(TOKEN_KEY, null) + ?.takeIf { it.isNotBlank() } + } + + override fun initialize(config: InitConfig, callback: (Result) -> Unit) { + val shopId = config.shopId + if (shopId.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "shopId is required", null))) + return + } + try { + val sdk = SDK.instance + sdk.initialize( + context = applicationContext, + shopId = shopId, + apiDomain = config.apiDomain, + stream = config.stream, + autoSendPushToken = config.autoSendPushToken, + needReInitialization = config.needReInitialization, + ) + + // Mirror REES46 entrypoint behaviour: show notifications on message. + sdk.setOnMessageListener { data -> + val payload = data.toPayload() + flutterApi?.onPushReceived(payload) { _ -> } + coroutineScope.launch { + val (images, hasError) = withContext(Dispatchers.IO) { + NotificationImageHelper.loadBitmaps(urls = data.image) + } + sdk.notificationHelper.createNotification( + context = applicationContext, + data = NotificationData( + id = data.id, + title = data.title, + body = data.body, + icon = data.icon, + type = data.type, + actions = data.actions, + actionUrls = data.actionUrls, + image = data.image, + event = data.event, + ), + images = images, + hasError = hasError, + ) + flutterApi?.onPushDelivered(payload) { _ -> } + } + } + + callback(Result.success(Unit)) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("init_failed", t.message, null))) + } + } + + override fun getRecommendation( + code: String, + paramsJson: String?, + callback: (Result) -> Unit, + ) { + if (code.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "code is required", null))) + return + } + try { + val params = buildRecommendationParams(paramsJson) + SDK.instance.recommendationManager.getExtendedRecommendation( + recommenderCode = code, + params = params, + onGetExtendedRecommendation = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("recommendation_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("recommendation_failed", t.message, null))) + } + } + + override fun getProductInfo(itemId: String, callback: (Result) -> Unit) { + if (itemId.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "itemId is required", null))) + return + } + try { + SDK.instance.productsManager.getProductInfo( + itemId = itemId, + listener = object : OnApiCallbackListener() { + override fun onSuccess(response: org.json.JSONObject?) { + if (response != null) { + callback(Result.success(response.toString())) + } else { + callback(Result.failure(FlutterError("product_info_failed", "Empty response", null))) + } + } + } + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("product_info_failed", t.message, null))) + } + } + + override fun getProductsList(paramsJson: String?, callback: (Result) -> Unit) { + try { + val p = if (!paramsJson.isNullOrBlank()) JSONObject(paramsJson) else null + val brands = p?.optString("brands")?.takeIf { it.isNotEmpty() } + val merchants = p?.optString("merchants")?.takeIf { it.isNotEmpty() } + val categories = p?.optString("categories")?.takeIf { it.isNotEmpty() } + val locations = p?.optString("locations")?.takeIf { it.isNotEmpty() } + val limit = if (p?.has("limit") == true) p.optInt("limit") else null + val page = if (p?.has("page") == true) p.optInt("page") else null + val filters: Map? = p?.optJSONObject("filters")?.let { obj -> + obj.keys().asSequence().associateWith { key -> obj.get(key) } + } + SDK.instance.productsManager.getProductsList( + brands = brands, + merchants = merchants, + categories = categories, + locations = locations, + limit = limit, + page = page, + filters = filters, + listener = object : OnApiCallbackListener() { + override fun onSuccess(response: org.json.JSONObject?) { + if (response != null) { + callback(Result.success(response.toString())) + } else { + callback(Result.failure(FlutterError("products_list_failed", "Empty response", null))) + } + } + } + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("products_list_failed", t.message, null))) + } + } + + override fun searchBlank(callback: (Result) -> Unit) { + try { + SDK.instance.searchManager.searchBlank( + onSearchBlank = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("search_blank_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("search_blank_failed", t.message, null))) + } + } + + override fun searchInstant( + query: String, + paramsJson: String?, + callback: (Result) -> Unit, + ) { + if (query.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "query is required", null))) + return + } + try { + val json = if (!paramsJson.isNullOrBlank()) JSONObject(paramsJson) else null + val locations = json?.optString("locations")?.takeIf { it.isNotEmpty() } + val excludedBrands = jsonArrayToStringList(json?.optJSONArray("excluded_brands")) + SDK.instance.searchManager.searchInstant( + query = query, + locations = locations, + excludedMerchants = null, + excludedBrands = excludedBrands, + onSearchInstant = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("search_instant_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("search_instant_failed", t.message, null))) + } + } + + override fun searchFull( + query: String, + paramsJson: String?, + callback: (Result) -> Unit, + ) { + if (query.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "query is required", null))) + return + } + try { + val params = buildSearchParams(paramsJson) + SDK.instance.searchManager.searchFull( + query = query, + searchParams = params, + onSearchFull = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("search_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("search_failed", t.message, null))) + } + } + + override fun getSid(): String = SDK.instance.getSid() + + override fun getDid(): String? = SDK.instance.getDid() + + override fun setProfile(params: ProfileParamsWire, callback: (Result) -> Unit) { + try { + val builder = ProfileParams.Builder() + params.email?.let { builder.put("email", it) } + params.phone?.let { builder.put("phone", it) } + params.loyaltyId?.let { builder.put("loyalty_id", it) } + params.firstName?.let { builder.put("first_name", it) } + params.lastName?.let { builder.put("last_name", it) } + params.birthday?.let { builder.put("birthday", it) } + params.age?.let { builder.put("age", it.toInt()) } + params.gender?.let { builder.put("gender", it) } + params.location?.let { builder.put("location", it) } + params.advertisingId?.let { builder.put("advertising_id", it) } + params.fbId?.let { builder.put("fb_id", it) } + params.vkId?.let { builder.put("vk_id", it) } + params.telegramId?.let { builder.put("telegram_id", it) } + params.loyaltyCardLocation?.let { builder.put("loyalty_card_location", it) } + params.loyaltyStatus?.let { builder.put("loyalty_status", it) } + params.loyaltyBonuses?.let { builder.put("loyalty_bonuses", it.toInt()) } + params.loyaltyBonusesToNextLevel?.let { builder.put("loyalty_bonuses_to_next_level", it.toInt()) } + params.boughtSomething?.let { builder.put("bought_something", if (it) "1" else "0") } + params.userId?.let { builder.put("id", it) } + params.customPropertiesJson?.let { json -> + val obj = JSONObject(json) + obj.keys().forEach { key -> builder.put(key, obj.getString(key)) } + } + SDK.instance.profile(builder.build(), object : OnApiCallbackListener() { + override fun onSuccess(response: JSONObject?) { + callback(Result.success(Unit)) + } + override fun onError(code: Int, msg: String?) { + callback(Result.failure(FlutterError("set_profile_failed", msg ?: "error $code", null))) + } + }) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("set_profile_failed", t.message, null))) + } + } + + override fun trackEvent( + event: String, + time: Long?, + category: String?, + label: String?, + value: Long?, + customFieldsJson: String?, + callback: (Result) -> Unit, + ) { + if (event.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "event is required", null))) + return + } + try { + val customFields = jsonObjectStringToMap(customFieldsJson) + FlutterTrackingBridge.postTrackEvent( + event = event, + time = time, + category = category, + label = label, + value = value, + customFields = customFields, + callback = callback, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("track_event_failed", t.message, null))) + } + } + + override 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, + ) { + if (orderId.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "orderId is required", null))) + return + } + if (items.isEmpty()) { + callback(Result.failure(FlutterError("bad_args", "items must be non-empty", null))) + return + } + try { + val recommendedSource = + if (recommendedSourceJson.isNullOrBlank()) { + null + } else { + JSONObject(recommendedSourceJson) + } + FlutterTrackingBridge.postTrackPurchase( + orderId = orderId, + orderPrice = orderPrice, + items = items, + deliveryType = deliveryType, + deliveryAddress = deliveryAddress, + paymentType = paymentType, + isTaxFree = isTaxFree, + promocode = promocode, + orderCash = orderCash, + orderBonuses = orderBonuses, + orderDelivery = orderDelivery, + orderDiscount = orderDiscount, + channel = channel, + custom = jsonObjectStringToMap(customJson), + recommendedSource = recommendedSource, + stream = stream, + segment = segment, + callback = callback, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("track_purchase_failed", t.message, null))) + } + } + + private fun handleNotificationLaunchIntent(intent: Intent?) { + val extras = intent?.extras ?: return + if (!extras.isPersonalizationNotificationClick()) { + return + } + val payload = extras.toStringPayloadMap() + val type = payload[NotificationClickExtraKeys.NOTIFICATION_TYPE] ?: return + val id = payload[NotificationClickExtraKeys.NOTIFICATION_ID] ?: return + val signature = "$type|$id" + if (!shouldProcessClickSignature(signature)) { + return + } + try { + SDK.instance.notificationClicked(extras) + flutterApi?.onPushClicked(payload) { _ -> } + } catch (_: Throwable) { + // SDK may not be initialized yet; ignore. + } + } + + companion object { + private const val NOTIFICATION_CLICK_DEBOUNCE_MS = 800L + private const val DEFAULT_STORAGE_KEY = "DEFAULT_STORAGE_KEY" + private const val TOKEN_KEY = "token" + + private var lastClickSignature: String? = null + private var lastClickAtElapsedMs: Long = 0L + + private fun shouldProcessClickSignature(signature: String): Boolean { + val now = SystemClock.elapsedRealtime() + if (signature == lastClickSignature && now - lastClickAtElapsedMs < NOTIFICATION_CLICK_DEBOUNCE_MS) { + return false + } + lastClickSignature = signature + lastClickAtElapsedMs = now + return true + } + } + +} + +private fun buildSearchParams(paramsJson: String?): NativeSearchParams { + val params = NativeSearchParams() + if (paramsJson.isNullOrBlank()) return params + val json = JSONObject(paramsJson) + json.optInt("limit").takeIf { it > 0 } + ?.let { params.put(NativeSearchParams.Parameter.LIMIT, it) } + json.optInt("page").takeIf { it > 0 } + ?.let { params.put(NativeSearchParams.Parameter.PAGE, it) } + json.optInt("category_limit").takeIf { it > 0 } + ?.let { params.put(NativeSearchParams.Parameter.CATEGORY_LIMIT, it) } + json.optInt("brand_limit").takeIf { it > 0 } + ?.let { params.put(NativeSearchParams.Parameter.BRAND_LIMIT, it) } + json.optString("sort_by").takeIf { it.isNotEmpty() } + ?.let { params.put(NativeSearchParams.Parameter.SORT_BY, it) } + json.optString("sort_dir").takeIf { it.isNotEmpty() } + ?.let { params.put(NativeSearchParams.Parameter.SORT_DIR, it) } + json.optString("locations").takeIf { it.isNotEmpty() } + ?.let { params.put(NativeSearchParams.Parameter.LOCATIONS, it) } + json.optString("brands").takeIf { it.isNotEmpty() } + ?.let { params.put(NativeSearchParams.Parameter.BRANDS, it) } + if (json.has("price_min")) + params.put(NativeSearchParams.Parameter.PRICE_MIN, json.getDouble("price_min").toString()) + if (json.has("price_max")) + params.put(NativeSearchParams.Parameter.PRICE_MAX, json.getDouble("price_max").toString()) + jsonArrayToStringArray(json.optJSONArray("categories")) + ?.let { params.put(NativeSearchParams.Parameter.CATEGORIES, it) } + jsonArrayToStringArray(json.optJSONArray("excluded_brands")) + ?.let { params.put(NativeSearchParams.Parameter.EXCLUDED_BRANDS, it) } + jsonArrayToStringArray(json.optJSONArray("colors")) + ?.let { params.put(NativeSearchParams.Parameter.COLORS, it) } + jsonArrayToStringArray(json.optJSONArray("fashion_sizes")) + ?.let { params.put(NativeSearchParams.Parameter.FASHION_SIZES, it) } + return params +} + +private fun jsonArrayToStringArray(arr: org.json.JSONArray?): Array? { + if (arr == null || arr.length() == 0) return null + return Array(arr.length()) { i -> arr.getString(i) } +} + +private fun jsonArrayToStringList(arr: org.json.JSONArray?): List? { + if (arr == null || arr.length() == 0) return null + return (0 until arr.length()).map { arr.getString(it) } +} + +private fun buildRecommendationParams(paramsJson: String?): Params { + val params = Params() + if (paramsJson.isNullOrBlank()) return params + val json = JSONObject(paramsJson) + json.optString("item_id").takeIf { it.isNotEmpty() } + ?.let { params.put(Params.Parameter.ITEM, it) } + json.optString("category_id").takeIf { it.isNotEmpty() } + ?.let { params.put(Params.Parameter.CATEGORY_ID, it) } + json.optString("locations").takeIf { it.isNotEmpty() } + ?.let { params.put(Params.Parameter.LOCATIONS, it) } + if (json.has("image_size")) + params.put(Params.Parameter.IMAGE_SIZE, json.getInt("image_size").toString()) + if (json.has("with_locations")) + params.put(Params.Parameter.WITH_LOCATIONS, json.getBoolean("with_locations").toString()) + return params +} + +private fun jsonObjectStringToMap(json: String?): Map? { + if (json.isNullOrBlank()) return null + val root = JSONObject(json) + return jsonObjectToNestedMap(root) +} + +private fun jsonObjectToNestedMap(obj: JSONObject): Map { + val out = LinkedHashMap() + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + val raw = obj.get(key) + out[key] = jsonValueToKotlin(raw) + } + return out +} + +private fun jsonValueToKotlin(value: Any?): Any? { + return when (value) { + null, JSONObject.NULL -> null + is JSONObject -> jsonObjectToNestedMap(value) + is JSONArray -> jsonArrayToList(value) + else -> value + } +} + +private fun jsonArrayToList(arr: JSONArray): List { + val list = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + list.add(jsonValueToKotlin(arr.opt(i))) + } + return list +} + +/** Matches [com.personalization.features.notification.domain.model.NotificationConstants]. */ +private object NotificationClickExtraKeys { + const val NOTIFICATION_TYPE = "NOTIFICATION_TYPE" + const val NOTIFICATION_ID = "NOTIFICATION_ID" +} + +private fun Bundle.isPersonalizationNotificationClick(): Boolean { + return !getString(NotificationClickExtraKeys.NOTIFICATION_TYPE).isNullOrBlank() && + !getString(NotificationClickExtraKeys.NOTIFICATION_ID).isNullOrBlank() +} + +private fun Bundle.toStringPayloadMap(): Map { + val map = mutableMapOf() + for (key in keySet()) { + val value = get(key) ?: continue + map[key] = value.toString() + } + return map +} + +private fun NotificationData.toPayload(): Map { + val map = mutableMapOf() + map["id"] = id + map["title"] = title + map["body"] = body + map["icon"] = icon + map["type"] = type + map["image"] = image + if (!actions.isNullOrEmpty()) { + val arr = JSONArray() + actions!!.forEach { action -> + arr.put( + JSONObject().put("action", action.action).put("title", action.title) + ) + } + map["actions"] = arr.toString() + } + if (!actionUrls.isNullOrEmpty()) { + map["actionUrls"] = actionUrls!!.joinToString(",") + } + event?.let { ev -> + val json = JSONObject() + json.put("type", ev.type ?: JSONObject.NULL) + json.put("uri", ev.uri ?: JSONObject.NULL) + ev.payload?.let { payload -> + try { + json.put("payload", JSONObject(payload)) + } catch (_: Exception) { + json.put("payload", payload.toString()) + } + } + map["event"] = json.toString() + } + return map +} 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 new file mode 100644 index 0000000..e4e16b8 --- /dev/null +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt @@ -0,0 +1,710 @@ +// Autogenerated from Pigeon (v25.5.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.rees46.rees46_flutter_sdk.pigeon + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object PersonalizationApiPigeonUtils { + + fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** Generated class from Pigeon that represents data sent in messages. */ +data class InitConfig ( + val shopId: String, + val apiDomain: String, + val stream: String, + val enableLogs: Boolean, + val autoSendPushToken: Boolean, + val sendAdvertisingId: Boolean, + val enableAutoPopupPresentation: Boolean, + val needReInitialization: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): InitConfig { + val shopId = pigeonVar_list[0] as String + val apiDomain = pigeonVar_list[1] as String + val stream = pigeonVar_list[2] as String + val enableLogs = pigeonVar_list[3] as Boolean + val autoSendPushToken = pigeonVar_list[4] as Boolean + val sendAdvertisingId = pigeonVar_list[5] as Boolean + val enableAutoPopupPresentation = pigeonVar_list[6] as Boolean + val needReInitialization = pigeonVar_list[7] as Boolean + return InitConfig(shopId, apiDomain, stream, enableLogs, autoSendPushToken, sendAdvertisingId, enableAutoPopupPresentation, needReInitialization) + } + } + fun toList(): List { + return listOf( + shopId, + apiDomain, + stream, + enableLogs, + autoSendPushToken, + sendAdvertisingId, + enableAutoPopupPresentation, + needReInitialization, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is InitConfig) { + return false + } + if (this === other) { + return true + } + return PersonalizationApiPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Wire format for one purchase line (maps to native [PurchaseItemRequest]; quantity not exposed). + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PurchaseLineItemWire ( + val id: String, + val amount: Long, + val price: Double, + val lineId: String? = null, + val fashionSize: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): PurchaseLineItemWire { + val id = pigeonVar_list[0] as String + val amount = pigeonVar_list[1] as Long + val price = pigeonVar_list[2] as Double + val lineId = pigeonVar_list[3] as String? + val fashionSize = pigeonVar_list[4] as String? + return PurchaseLineItemWire(id, amount, price, lineId, fashionSize) + } + } + fun toList(): List { + return listOf( + id, + amount, + price, + lineId, + fashionSize, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PurchaseLineItemWire) { + return false + } + if (this === other) { + return true + } + return PersonalizationApiPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Wire format for profile fields sent to native SDK. + * All fields are optional — only non-null values are forwarded. + * [birthday] must be a "yyyy-MM-dd" string. + * [gender] must be "m" or "f". + * [customPropertiesJson] is a JSON object string or null. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ProfileParamsWire ( + val email: String? = null, + val phone: String? = null, + val loyaltyId: String? = null, + val firstName: String? = null, + val lastName: String? = null, + val birthday: String? = null, + val age: Long? = null, + val gender: String? = null, + val location: String? = null, + val advertisingId: String? = null, + val fbId: String? = null, + val vkId: String? = null, + val telegramId: String? = null, + val loyaltyCardLocation: String? = null, + val loyaltyStatus: String? = null, + val loyaltyBonuses: Long? = null, + val loyaltyBonusesToNextLevel: Long? = null, + val boughtSomething: Boolean? = null, + val userId: String? = null, + val customPropertiesJson: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ProfileParamsWire { + val email = pigeonVar_list[0] as String? + val phone = pigeonVar_list[1] as String? + val loyaltyId = pigeonVar_list[2] as String? + val firstName = pigeonVar_list[3] as String? + val lastName = pigeonVar_list[4] as String? + val birthday = pigeonVar_list[5] as String? + val age = pigeonVar_list[6] as Long? + val gender = pigeonVar_list[7] as String? + val location = pigeonVar_list[8] as String? + val advertisingId = pigeonVar_list[9] as String? + val fbId = pigeonVar_list[10] as String? + val vkId = pigeonVar_list[11] as String? + val telegramId = pigeonVar_list[12] as String? + val loyaltyCardLocation = pigeonVar_list[13] as String? + val loyaltyStatus = pigeonVar_list[14] as String? + val loyaltyBonuses = pigeonVar_list[15] as Long? + val loyaltyBonusesToNextLevel = pigeonVar_list[16] as Long? + val boughtSomething = pigeonVar_list[17] as Boolean? + val userId = pigeonVar_list[18] as String? + val customPropertiesJson = pigeonVar_list[19] as String? + return ProfileParamsWire(email, phone, loyaltyId, firstName, lastName, birthday, age, gender, location, advertisingId, fbId, vkId, telegramId, loyaltyCardLocation, loyaltyStatus, loyaltyBonuses, loyaltyBonusesToNextLevel, boughtSomething, userId, customPropertiesJson) + } + } + fun toList(): List { + return listOf( + email, + phone, + loyaltyId, + firstName, + lastName, + birthday, + age, + gender, + location, + advertisingId, + fbId, + vkId, + telegramId, + loyaltyCardLocation, + loyaltyStatus, + loyaltyBonuses, + loyaltyBonusesToNextLevel, + boughtSomething, + userId, + customPropertiesJson, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ProfileParamsWire) { + return false + } + if (this === other) { + return true + } + return PersonalizationApiPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class PersonalizationApiPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + InitConfig.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + PurchaseLineItemWire.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + ProfileParamsWire.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is InitConfig -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is PurchaseLineItemWire -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is ProfileParamsWire -> { + stream.write(131) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface PersonalizationHostApi { + fun initialize(config: InitConfig, callback: (Result) -> Unit) + fun getPlatformVersion(): String + /** Returns the push token stored by the native SDK (if any). */ + fun getStoredPushToken(): String? + /** [customFieldsJson] is JSON object string or null (maps to native custom fields map). */ + fun trackEvent(event: String, time: Long?, category: String?, label: String?, value: Long?, customFieldsJson: String?, callback: (Result) -> Unit) + fun setProfile(params: ProfileParamsWire, callback: (Result) -> Unit) + /** + * Returns the recommendation block as a JSON string. + * [paramsJson] is a JSON object string with optional filter parameters. + * Dart layer parses the result into [RecommendationResponse]. + */ + fun getRecommendation(code: String, paramsJson: String?, callback: (Result) -> Unit) + /** Returns the current session ID from the native SDK. */ + fun getSid(): String + /** Returns the device ID assigned by the native SDK, or null before first sync. */ + fun getDid(): String? + /** + * Returns a single product's details as a JSON string. + * Dart layer parses the result into [Product]. + */ + fun getProductInfo(itemId: String, callback: (Result) -> Unit) + /** + * Returns a paginated product catalog list as a JSON string. + * [paramsJson] is a JSON object with optional filter fields. + * Dart layer parses the result into [ProductsListResponse]. + */ + fun getProductsList(paramsJson: String?, callback: (Result) -> Unit) + /** + * Returns blank search results (trending/popular) as a JSON string. + * No parameters — the native SDK decides what to return based on shop config. + * Dart layer parses the result into [SearchBlankResponse]. + */ + fun searchBlank(callback: (Result) -> Unit) + /** + * Returns instant (typeahead) search results as a JSON string. + * [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). + * Dart layer parses the result into [SearchInstantResponse]. + */ + fun searchInstant(query: String, paramsJson: String?, callback: (Result) -> Unit) + /** + * Returns full search results as a JSON string. + * [paramsJson] is a JSON object string with optional search parameters. + * Dart layer parses the result into [SearchFullResponse]. + */ + fun searchFull(query: String, paramsJson: 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) + + companion object { + /** The codec used by PersonalizationHostApi. */ + val codec: MessageCodec by lazy { + PersonalizationApiPigeonCodec() + } + /** Sets up an instance of `PersonalizationHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: PersonalizationHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.initialize$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val configArg = args[0] as InitConfig + api.initialize(configArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + reply.reply(PersonalizationApiPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getPlatformVersion$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getPlatformVersion()) + } catch (exception: Throwable) { + PersonalizationApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getStoredPushToken$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getStoredPushToken()) + } catch (exception: Throwable) { + PersonalizationApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackEvent$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val eventArg = args[0] as String + val timeArg = args[1] as Long? + val categoryArg = args[2] as String? + val labelArg = args[3] as String? + val valueArg = args[4] as Long? + val customFieldsJsonArg = args[5] as String? + api.trackEvent(eventArg, timeArg, categoryArg, labelArg, valueArg, customFieldsJsonArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + reply.reply(PersonalizationApiPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.setProfile$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val paramsArg = args[0] as ProfileParamsWire + api.setProfile(paramsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + reply.reply(PersonalizationApiPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getRecommendation$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val codeArg = args[0] as String + val paramsJsonArg = args[1] as String? + api.getRecommendation(codeArg, paramsJsonArg) { 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.getSid$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getSid()) + } catch (exception: Throwable) { + PersonalizationApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getDid()) + } catch (exception: Throwable) { + PersonalizationApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductInfo$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val itemIdArg = args[0] as String + api.getProductInfo(itemIdArg) { 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.getProductsList$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val paramsJsonArg = args[0] as String? + api.getProductsList(paramsJsonArg) { 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.searchBlank$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.searchBlank{ 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.searchInstant$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val queryArg = args[0] as String + val paramsJsonArg = args[1] as String? + api.searchInstant(queryArg, paramsJsonArg) { 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.searchFull$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val queryArg = args[0] as String + val paramsJsonArg = args[1] as String? + api.searchFull(queryArg, paramsJsonArg) { 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) { + channel.setMessageHandler { message, reply -> + val args = message as List + val orderIdArg = args[0] as String + val orderPriceArg = args[1] as Double + val itemsArg = args[2] as List + val deliveryTypeArg = args[3] as String? + val deliveryAddressArg = args[4] as String? + val paymentTypeArg = args[5] as String? + val isTaxFreeArg = args[6] as Boolean + val promocodeArg = args[7] as String? + val orderCashArg = args[8] as Double? + val orderBonusesArg = args[9] as Double? + val orderDeliveryArg = args[10] as Double? + val orderDiscountArg = args[11] as Double? + val channelArg = args[12] as String? + val customJsonArg = args[13] as String? + val recommendedSourceJsonArg = args[14] as String? + val streamArg = args[15] as String? + val segmentArg = args[16] as String? + api.trackPurchase(orderIdArg, orderPriceArg, itemsArg, deliveryTypeArg, deliveryAddressArg, paymentTypeArg, isTaxFreeArg, promocodeArg, orderCashArg, orderBonusesArg, orderDeliveryArg, orderDiscountArg, channelArg, customJsonArg, recommendedSourceJsonArg, streamArg, segmentArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + reply.reply(PersonalizationApiPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class PersonalizationFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by PersonalizationFlutterApi. */ + val codec: MessageCodec by lazy { + PersonalizationApiPigeonCodec() + } + } + fun onPushReceived(payloadArg: Map, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(payloadArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(PersonalizationApiPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onPushDelivered(payloadArg: Map, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(payloadArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(PersonalizationApiPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onPushClicked(payloadArg: Map, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(payloadArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(PersonalizationApiPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/android/src/test/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPluginTest.kt b/android/src/test/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPluginTest.kt new file mode 100644 index 0000000..256c0a5 --- /dev/null +++ b/android/src/test/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPluginTest.kt @@ -0,0 +1,20 @@ +package com.rees46.rees46_flutter_sdk + +import kotlin.test.Test +import kotlin.test.assertTrue + +/* + * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * + * Once you have built the plugin's example app, you can run these tests from the command + * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or + * you can run them directly from IDEs that support JUnit such as Android Studio. + */ + +internal class Rees46FlutterSdkPluginTest { + @Test + fun getPlatformVersion_containsAndroidWord() { + val plugin = Rees46FlutterSdkPlugin() + assertTrue(plugin.getPlatformVersion().startsWith("Android ")) + } +} diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..c106ba1 --- /dev/null +++ b/example/README.md @@ -0,0 +1,17 @@ +# rees46_flutter_sdk_example + +Demonstrates how to use the rees46_flutter_sdk plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..3c4b1ed --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") + // Processes google-services.json so the native SDK's FCM (firebase-messaging, + // pulled transitively by rees46-sdk) can auto-initialize FirebaseApp. + id("com.google.gms.google-services") +} + +android { + namespace = "com.rees46.rees46_flutter_sdk_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.rees46.rees46_flutter_sdk_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + + testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} + +dependencies { + androidTestUtil("androidx.test:orchestrator:1.5.1") +} diff --git a/example/android/app/google-services.json b/example/android/app/google-services.json new file mode 100644 index 0000000..f292eea --- /dev/null +++ b/example/android/app/google-services.json @@ -0,0 +1,238 @@ +{ + "project_info": { + "project_number": "605730184710", + "project_id": "rees46-com", + "storage_bucket": "rees46-com.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:6c640baac4dda9e05b6d87", + "android_client_info": { + "package_name": "com.demoapp" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:7b6eb053665460b55b6d87", + "android_client_info": { + "package_name": "com.example.rees46_java_app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:198526dca19b16ec5b6d87", + "android_client_info": { + "package_name": "com.personaclick.sample" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:e3fd667d60c9bdb75b6d87", + "android_client_info": { + "package_name": "com.personalizatio.sample" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:2068bac26248342c5b6d87", + "android_client_info": { + "package_name": "com.personalization.demo" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:f4dd23bdbd308bb05b6d87", + "android_client_info": { + "package_name": "com.personalization.sample" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:60bfd61d91f6b9e15b6d87", + "android_client_info": { + "package_name": "com.reactnativedevapp" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:ba86623dfc47ff155b6d87", + "android_client_info": { + "package_name": "com.rees46.loyaltyProgram" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:beded358e1393fe55b6d87", + "android_client_info": { + "package_name": "com.rees46.rees46_flutter_sdk_example" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:75abe9965c01c1c45b6d87", + "android_client_info": { + "package_name": "com.rees46.sample" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:9f9c2bc678183a005b6d87", + "android_client_info": { + "package_name": "com.sdkdev" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:605730184710:android:4cf54376b6a2868e5b6d87", + "android_client_info": { + "package_name": "rees46.demo_android" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAMI5DiiCqeZ2JV2m6r0Be520N39WKh_TA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/example/android/app/src/androidTest/java/com/rees46/rees46_flutter_sdk_example/MainActivityTest.java b/example/android/app/src/androidTest/java/com/rees46/rees46_flutter_sdk_example/MainActivityTest.java new file mode 100644 index 0000000..c9993ce --- /dev/null +++ b/example/android/app/src/androidTest/java/com/rees46/rees46_flutter_sdk_example/MainActivityTest.java @@ -0,0 +1,32 @@ +package com.rees46.rees46_flutter_sdk_example; + +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import pl.leancode.patrol.PatrolJUnitRunner; + +@RunWith(Parameterized.class) +public class MainActivityTest { + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.setUp(MainActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + private final String dartTestName; + + @Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } +} + diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..beb2859 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/rees46/rees46_flutter_sdk_example/MainActivity.kt b/example/android/app/src/main/kotlin/com/rees46/rees46_flutter_sdk_example/MainActivity.kt new file mode 100644 index 0000000..e9ff4da --- /dev/null +++ b/example/android/app/src/main/kotlin/com/rees46/rees46_flutter_sdk_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.rees46.rees46_flutter_sdk_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..eeea458 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,25 @@ +allprojects { + repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..51a76cd --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.3" apply false +} + +include(":app") diff --git a/example/integration_test/init_flow_test.dart b/example/integration_test/init_flow_test.dart new file mode 100644 index 0000000..85a2351 --- /dev/null +++ b/example/integration_test/init_flow_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart' as app; + +void main() { + patrolTest('auto-initializes on startup with hardcoded config', ($) async { + await $.pumpWidgetAndSettle(const app.App()); + + await $( + 'Status: Initialized', + ).waitUntilVisible(timeout: const Duration(seconds: 30)); + }); + + patrolTest('tracking buttons are visible after auto-initialization', ( + $, + ) async { + await $.pumpWidgetAndSettle(const app.App()); + + await $( + 'Status: Initialized', + ).waitUntilVisible(timeout: const Duration(seconds: 30)); + await $('Send demo trackEvent').scrollTo(); + await $('Send demo trackPurchase').scrollTo(); + }); + + patrolTest( + 'Re-initialize button re-runs initialization and returns to Initialized', + ($) async { + await $.pumpWidgetAndSettle(const app.App()); + + await $( + 'Status: Initialized', + ).waitUntilVisible(timeout: const Duration(seconds: 30)); + + await $('Re-initialize').scrollTo(); + await $('Re-initialize').tap(); + + await $( + 'Status: Initialized', + ).waitUntilVisible(timeout: const Duration(seconds: 30)); + await $( + 'Status: Initialized', + ).scrollTo(scrollDirection: AxisDirection.up); + }, + ); +} diff --git a/example/integration_test/patrol_smoke_test.dart b/example/integration_test/patrol_smoke_test.dart new file mode 100644 index 0000000..bb7c4b5 --- /dev/null +++ b/example/integration_test/patrol_smoke_test.dart @@ -0,0 +1,29 @@ +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart' as app; + +void main() { + patrolTest('app launches and all key sections are visible', ($) async { + await $.pumpWidgetAndSettle(const app.App()); + + await $('REES46 SDK init demo').waitUntilVisible(); + await $('Initialization').waitUntilVisible(); + await $('Stored push token').waitUntilVisible(); + await $('Tracking').scrollTo(); + await $('Send demo trackEvent').scrollTo(); + await $('Send demo trackPurchase').scrollTo(); + }); + + patrolTest( + 'auto-initializes on startup and exposes the Re-initialize button', + ($) async { + await $.pumpWidgetAndSettle(const app.App()); + + // The demo uses hardcoded config and initializes itself on launch. + await $( + 'Status: Initialized', + ).waitUntilVisible(timeout: const Duration(seconds: 30)); + await $('Re-initialize').scrollTo(); + }, + ); +} diff --git a/example/integration_test/product_info_sdk_test.dart b/example/integration_test/product_info_sdk_test.dart new file mode 100644 index 0000000..6253bc8 --- /dev/null +++ b/example/integration_test/product_info_sdk_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart' as app; + +import 'test_config.dart'; + +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('getProductInfo — returns product name for valid ID', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_product_id')), + TestConfig.productId, + ); + await $.tester.pump(); + + await $('Get Product Info').scrollTo(); + await $('Get Product Info').tap(); + await $('Get Product Info').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_product_info_error')), findsNothing); + + final nameText = _labelText($, 'lbl_product_info_name'); + expect(nameText, startsWith('Name:')); + expect(nameText.length, greaterThan('Name:'.length)); + }); + + patrolTest('getProductInfo — empty ID is no-op in the UI', ($) async { + await _initializeSdk($); + + await $('Get Product Info').scrollTo(); + await $('Get Product Info').tap(); + await $.pumpAndSettle(); + + expect(find.byKey(const Key('lbl_product_info_name')), findsNothing); + expect(find.byKey(const Key('lbl_product_info_error')), findsNothing); + }); + + patrolTest('getProductInfo — invalid ID shows error', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_product_id')), + 'nonexistent_product_id_xyz', + ); + await $.tester.pump(); + + await $('Get Product Info').scrollTo(); + await $('Get Product Info').tap(); + await $('Get Product Info').waitUntilVisible(); + + final hasName = find + .byKey(const Key('lbl_product_info_name')) + .evaluate() + .isNotEmpty; + final hasError = find + .byKey(const Key('lbl_product_info_error')) + .evaluate() + .isNotEmpty; + expect(hasName || hasError, isTrue); + }); +} diff --git a/example/integration_test/products_list_sdk_test.dart b/example/integration_test/products_list_sdk_test.dart new file mode 100644 index 0000000..7f5422a --- /dev/null +++ b/example/integration_test/products_list_sdk_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_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('getProductsList — returns non-negative total', ($) async { + await _initializeSdk($); + + await $('Get Products List').scrollTo(); + await $('Get Products List').tap(); + await $('Get Products List').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_products_list_error')), findsNothing); + + final totalText = _labelText($, 'lbl_products_list_total'); + expect(totalText, startsWith('Total:')); + final n = int.tryParse(totalText.replaceFirst('Total: ', '')); + expect(n, isNotNull); + expect(n, greaterThanOrEqualTo(0)); + }); + + patrolTest('getProductsList — consistent total on repeated calls', ($) async { + await _initializeSdk($); + + await $('Get Products List').scrollTo(); + await $('Get Products List').tap(); + await $('Get Products List').waitUntilVisible(); + final first = _labelText($, 'lbl_products_list_total'); + + await $('Get Products List').scrollTo(); + await $('Get Products List').tap(); + await $('Get Products List').waitUntilVisible(); + final second = _labelText($, 'lbl_products_list_total'); + + expect(first, equals(second)); + }); + + patrolTest('getProductsList — no error after init', ($) async { + await _initializeSdk($); + + await $('Get Products List').scrollTo(); + await $('Get Products List').tap(); + await $('Get Products List').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_products_list_error')), findsNothing); + expect(find.byKey(const Key('lbl_products_list_total')), findsOneWidget); + }); +} diff --git a/example/integration_test/profile_sdk_test.dart b/example/integration_test/profile_sdk_test.dart new file mode 100644 index 0000000..31c3404 --- /dev/null +++ b/example/integration_test/profile_sdk_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_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() { + // --------------------------------------------------------------------------- + // getSid + // --------------------------------------------------------------------------- + patrolTest('getSid — returns non-empty string after init', ($) async { + await _initializeSdk($); + + await $('Get SID').scrollTo(); + await $('Get SID').tap(); + await $.pumpAndSettle(); + + final sid = _labelText($, 'lbl_sid'); + expect(sid, isNotEmpty); + expect(sid, isNot('—')); + expect(sid, isNot(contains('Error'))); + }); + + patrolTest('getSid — consistent value on repeated calls', ($) async { + await _initializeSdk($); + + await $('Get SID').scrollTo(); + await $('Get SID').tap(); + await $.pumpAndSettle(); + final first = _labelText($, 'lbl_sid'); + + await $('Get SID').scrollTo(); + await $('Get SID').tap(); + await $.pumpAndSettle(); + final second = _labelText($, 'lbl_sid'); + + expect(first, equals(second)); + }); + + // --------------------------------------------------------------------------- + // getDid + // --------------------------------------------------------------------------- + patrolTest('getDid — does not crash after init', ($) async { + await _initializeSdk($); + + await $('Get DID').scrollTo(); + await $('Get DID').tap(); + await $.pumpAndSettle(); + + final did = _labelText($, 'lbl_did'); + expect(did, isNot(contains('Error'))); + }); + + patrolTest('getDid — result is a DID string or null before first sync', ( + $, + ) async { + await _initializeSdk($); + + await $('Get DID').scrollTo(); + await $('Get DID').tap(); + await $.pumpAndSettle(); + + final did = _labelText($, 'lbl_did'); + // Either the SDK assigned a DID or returned null before the first API sync. + expect(did == 'null' || (did.isNotEmpty && did != '—'), isTrue); + }); + + // --------------------------------------------------------------------------- + // setProfile + // --------------------------------------------------------------------------- + patrolTest('setProfile — completes without error', ($) async { + await _initializeSdk($); + + await $('Set Profile').scrollTo(); + await $('Set Profile').tap(); + await $.pumpAndSettle(); + + final status = _labelText($, 'lbl_profile_status'); + expect(status, equals('Profile set')); + }); + + patrolTest('setProfile — can be called multiple times', ($) async { + await _initializeSdk($); + + await $('Set Profile').scrollTo(); + await $('Set Profile').tap(); + await $.pumpAndSettle(); + expect(_labelText($, 'lbl_profile_status'), equals('Profile set')); + + await $('Set Profile').scrollTo(); + await $('Set Profile').tap(); + await $.pumpAndSettle(); + expect(_labelText($, 'lbl_profile_status'), equals('Profile set')); + }); +} diff --git a/example/integration_test/push_token_test.dart b/example/integration_test/push_token_test.dart new file mode 100644 index 0000000..1a762c4 --- /dev/null +++ b/example/integration_test/push_token_test.dart @@ -0,0 +1,32 @@ +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart' as app; + +void main() { + patrolTest('push token section is visible on launch', ($) async { + await $.pumpWidgetAndSettle(const app.App()); + + await $('Stored push token').waitUntilVisible(); + }); + + patrolTest('Refresh and Copy controls are visible', ($) async { + await $.pumpWidgetAndSettle(const app.App()); + + await $('Refresh').waitUntilVisible(); + await $('Copy').waitUntilVisible(); + }); + + patrolTest('refreshing the token after auto-init does not crash', ($) async { + await $.pumpWidgetAndSettle(const app.App()); + + await $( + 'Status: Initialized', + ).waitUntilVisible(timeout: const Duration(seconds: 30)); + + await $('Refresh').tap(); + await $.pumpAndSettle(); + + // Section stays present whether or not a token was delivered yet. + await $('Stored push token').waitUntilVisible(); + }); +} diff --git a/example/integration_test/recommendation_sdk_test.dart b/example/integration_test/recommendation_sdk_test.dart new file mode 100644 index 0000000..e2857df --- /dev/null +++ b/example/integration_test/recommendation_sdk_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart' as app; + +import 'test_config.dart'; + +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() { + // --------------------------------------------------------------------------- + // getRecommendation + // --------------------------------------------------------------------------- + patrolTest('getRecommendation — returns title and product list', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_rec_block_code')), + TestConfig.recommendationBlockCode, + ); + await $.tester.pump(); + + await $('Get Recommendations').scrollTo(); + await $('Get Recommendations').tap(); + + // Wait for loading to finish — button text reverts to 'Get Recommendations'. + await $('Get Recommendations').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_rec_error')), findsNothing); + + final title = _labelText($, 'lbl_rec_title'); + expect(title, isNot(contains('Error'))); + expect(title, startsWith('Title:')); + }); + + patrolTest('getRecommendation — product count is non-negative', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_rec_block_code')), + TestConfig.recommendationBlockCode, + ); + await $.tester.pump(); + await $('Get Recommendations').scrollTo(); + await $('Get Recommendations').tap(); + await $('Get Recommendations').waitUntilVisible(); + + final countText = _labelText($, 'lbl_rec_count'); + // Text is "Products: N" — extract the number. + final n = int.tryParse(countText.replaceFirst('Products: ', '')); + expect(n, isNotNull); + expect(n, greaterThanOrEqualTo(0)); + }); + + patrolTest('getRecommendation — empty block code does not crash', ($) async { + await _initializeSdk($); + + // Leave block code field empty and tap. + await $('Get Recommendations').scrollTo(); + await $('Get Recommendations').tap(); + await $.pumpAndSettle(); + + // No error label should appear — empty code is a no-op in the UI. + expect(find.byKey(const Key('lbl_rec_title')), findsNothing); + expect(find.byKey(const Key('lbl_rec_error')), findsNothing); + }); + + patrolTest('getRecommendation — invalid block code shows error', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_rec_block_code')), + 'nonexistent_block_xyz', + ); + await $.tester.pump(); + await $('Get Recommendations').scrollTo(); + await $('Get Recommendations').tap(); + await $('Get Recommendations').waitUntilVisible(); + + // Either an error label appears, or we get an empty product list — both are valid. + final hasError = find + .byKey(const Key('lbl_rec_error')) + .evaluate() + .isNotEmpty; + final hasTitle = find + .byKey(const Key('lbl_rec_title')) + .evaluate() + .isNotEmpty; + expect(hasError || hasTitle, isTrue); + }); +} diff --git a/example/integration_test/search_blank_sdk_test.dart b/example/integration_test/search_blank_sdk_test.dart new file mode 100644 index 0000000..845b599 --- /dev/null +++ b/example/integration_test/search_blank_sdk_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_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('searchBlank — returns products and suggests counts', ($) async { + await _initializeSdk($); + + await $('Search Blank').scrollTo(); + await $('Search Blank').tap(); + await $('Search Blank').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_search_blank_error')), findsNothing); + + final productsText = _labelText($, 'lbl_search_blank_products'); + final suggestsText = _labelText($, 'lbl_search_blank_suggests'); + + expect(productsText, startsWith('Products:')); + expect(suggestsText, startsWith('Suggests:')); + + final products = int.tryParse(productsText.replaceFirst('Products: ', '')); + final suggests = int.tryParse(suggestsText.replaceFirst('Suggests: ', '')); + expect(products, isNotNull); + expect(suggests, isNotNull); + expect(products, greaterThanOrEqualTo(0)); + expect(suggests, greaterThanOrEqualTo(0)); + }); + + patrolTest('searchBlank — can be called multiple times', ($) async { + await _initializeSdk($); + + await $('Search Blank').scrollTo(); + await $('Search Blank').tap(); + await $('Search Blank').waitUntilVisible(); + final firstProducts = _labelText($, 'lbl_search_blank_products'); + + await $('Search Blank').scrollTo(); + await $('Search Blank').tap(); + await $('Search Blank').waitUntilVisible(); + final secondProducts = _labelText($, 'lbl_search_blank_products'); + + expect(firstProducts, equals(secondProducts)); + }); + + patrolTest('searchBlank — no error on first call after init', ($) async { + await _initializeSdk($); + + await $('Search Blank').scrollTo(); + await $('Search Blank').tap(); + await $('Search Blank').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_search_blank_error')), findsNothing); + expect(find.byKey(const Key('lbl_search_blank_products')), findsOneWidget); + }); +} diff --git a/example/integration_test/search_instant_sdk_test.dart b/example/integration_test/search_instant_sdk_test.dart new file mode 100644 index 0000000..8218f9b --- /dev/null +++ b/example/integration_test/search_instant_sdk_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart' as app; + +import 'test_config.dart'; + +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('searchInstant — returns products_total for valid query', ( + $, + ) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_search_instant_query')), + TestConfig.searchQuery, + ); + await $.tester.pump(); + + await $('Search Instant').tap(); + await $('Search Instant').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_search_instant_error')), findsNothing); + + final totalText = _labelText($, 'lbl_search_instant_total'); + expect(totalText, startsWith('Total:')); + final n = int.tryParse(totalText.replaceFirst('Total: ', '')); + expect(n, isNotNull); + expect(n, greaterThanOrEqualTo(0)); + }); + + patrolTest('searchInstant — empty query is no-op in the UI', ($) async { + await _initializeSdk($); + + await $('Search Instant').tap(); + await $.pumpAndSettle(); + + expect(find.byKey(const Key('lbl_search_instant_total')), findsNothing); + expect(find.byKey(const Key('lbl_search_instant_error')), findsNothing); + }); + + patrolTest('searchInstant — consistent total on repeated calls', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_search_instant_query')), + TestConfig.searchQuery, + ); + await $.tester.pump(); + + await $('Search Instant').tap(); + await $('Search Instant').waitUntilVisible(); + final first = _labelText($, 'lbl_search_instant_total'); + + await $('Search Instant').tap(); + await $('Search Instant').waitUntilVisible(); + final second = _labelText($, 'lbl_search_instant_total'); + + expect(first, equals(second)); + }); + + patrolTest('searchInstant — unknown query does not crash', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_search_instant_query')), + 'xyzunknown99887766', + ); + await $.tester.pump(); + + await $('Search Instant').tap(); + await $('Search Instant').waitUntilVisible(); + + final hasTotal = find + .byKey(const Key('lbl_search_instant_total')) + .evaluate() + .isNotEmpty; + final hasError = find + .byKey(const Key('lbl_search_instant_error')) + .evaluate() + .isNotEmpty; + expect(hasTotal || hasError, isTrue); + }); +} diff --git a/example/integration_test/search_sdk_test.dart b/example/integration_test/search_sdk_test.dart new file mode 100644 index 0000000..4c60870 --- /dev/null +++ b/example/integration_test/search_sdk_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart' as app; + +import 'test_config.dart'; + +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('searchFull — returns products_total for valid query', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_search_query')), + TestConfig.searchQuery, + ); + await $.tester.pump(); + + await $('Search').scrollTo(); + await $('Search').tap(); + await $('Search').waitUntilVisible(); + + expect(find.byKey(const Key('lbl_search_error')), findsNothing); + + final totalText = _labelText($, 'lbl_search_total'); + expect(totalText, startsWith('Total:')); + final n = int.tryParse(totalText.replaceFirst('Total: ', '')); + expect(n, isNotNull); + expect(n, greaterThanOrEqualTo(0)); + }); + + patrolTest('searchFull — empty query is no-op in the UI', ($) async { + await _initializeSdk($); + + await $('Search').scrollTo(); + await $('Search').tap(); + await $.pumpAndSettle(); + + expect(find.byKey(const Key('lbl_search_total')), findsNothing); + expect(find.byKey(const Key('lbl_search_error')), findsNothing); + }); + + patrolTest('searchFull — can be called multiple times', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_search_query')), + TestConfig.searchQuery, + ); + await $.tester.pump(); + + await $('Search').scrollTo(); + await $('Search').tap(); + await $('Search').waitUntilVisible(); + final first = _labelText($, 'lbl_search_total'); + + await $('Search').scrollTo(); + await $('Search').tap(); + await $('Search').waitUntilVisible(); + final second = _labelText($, 'lbl_search_total'); + + expect(first, equals(second)); + }); + + patrolTest('searchFull — unknown query returns result or error, no crash', ( + $, + ) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_search_query')), + 'xyznotexistentproduct12345', + ); + await $.tester.pump(); + + await $('Search').scrollTo(); + await $('Search').tap(); + await $('Search').waitUntilVisible(); + + final hasTotal = find + .byKey(const Key('lbl_search_total')) + .evaluate() + .isNotEmpty; + final hasError = find + .byKey(const Key('lbl_search_error')) + .evaluate() + .isNotEmpty; + expect(hasTotal || hasError, isTrue); + }); +} diff --git a/example/integration_test/test_bundle.dart b/example/integration_test/test_bundle.dart new file mode 100644 index 0000000..4cc56c4 --- /dev/null +++ b/example/integration_test/test_bundle.dart @@ -0,0 +1,89 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND AND DO NOT COMMIT TO VERSION CONTROL +// ignore_for_file: type=lint, invalid_use_of_internal_member + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:patrol/src/platform/contracts/contracts.dart'; +import 'package:test_api/src/backend/invoker.dart'; + +// START: GENERATED TEST IMPORTS +import 'search_blank_sdk_test.dart' as search_blank_sdk_test; +// END: GENERATED TEST IMPORTS + +Future main() async { + // This is the entrypoint of the bundled Dart test. + // + // Its responsibilities are: + // * Running a special Dart test that runs before all the other tests and + // explores the hierarchy of groups and tests. + // * Hosting a PatrolAppService, which the native side of Patrol uses to get + // the Dart tests, and to request execution of a specific Dart test. + // + // When running on Android, the Android Test Orchestrator, before running the + // tests, makes an initial run to gather the tests that it will later run. The + // native side of Patrol (specifically: PatrolJUnitRunner class) is hooked + // into the Android Test Orchestrator lifecycle and knows when that initial + // run happens. When it does, PatrolJUnitRunner makes an RPC call to + // PatrolAppService and asks it for Dart tests. + // + // When running on iOS, the native side of Patrol (specifically: the + // PATROL_INTEGRATION_TEST_IOS_RUNNER macro) makes an initial run to gather + // the tests that it will later run (same as the Android). During that initial + // run, it makes an RPC call to PatrolAppService and asks it for Dart tests. + // + // Once the native runner has the list of Dart tests, it dynamically creates + // native test cases from them. On Android, this is done using the + // Parametrized JUnit runner. On iOS, new test case methods are swizzled into + // the RunnerUITests class, taking advantage of the very dynamic nature of + // Objective-C runtime. + // + // Execution of these dynamically created native test cases is then fully + // managed by the underlying native test framework (JUnit on Android, XCTest + // on iOS). The native test cases do only one thing - request execution of the + // Dart test (out of which they had been created) and wait for it to complete. + // The result of running the Dart test is the result of the native test case. + + final platformAutomator = PlatformAutomator( + config: PlatformAutomatorConfig.defaultConfig(), + ); + await platformAutomator.initialize(); + final binding = PatrolBinding.ensureInitialized(platformAutomator); + final testExplorationCompleter = Completer(); + + // A special test to explore the hierarchy of groups and tests. This is a hack + // around https://github.com/dart-lang/test/issues/1998. + // + // This test must be the first to run. If not, the native side likely won't + // receive any tests, and everything will fall apart. + test('patrol_test_explorer', () { + // Maybe somewhat counterintuitively, this callback runs *after* the calls + // to group() below. + final topLevelGroup = Invoker.current!.liveTest.groups.first; + final dartTestGroup = createDartTestGroup( + topLevelGroup, + tags: null, + excludeTags: null, + ); + testExplorationCompleter.complete(dartTestGroup); + print('patrol_test_explorer: obtained Dart-side test hierarchy:'); + reportGroupStructure(dartTestGroup); + }); + + // START: GENERATED TEST GROUPS + group('search_blank_sdk_test', search_blank_sdk_test.main); + // END: GENERATED TEST GROUPS + + final dartTestGroup = await testExplorationCompleter.future; + final appService = PatrolAppService(topLevelDartTestGroup: dartTestGroup); + binding.patrolAppService = appService; + await runAppService(appService); + + // Until now, the native test runner was waiting for us, the Dart side, to + // come alive. Now that we did, let's tell it that we're ready to be asked + // about Dart tests. + await platformAutomator.markPatrolAppServiceReady(); + + await appService.testExecutionCompleted; +} diff --git a/example/integration_test/test_config.dart b/example/integration_test/test_config.dart new file mode 100644 index 0000000..78f06bb --- /dev/null +++ b/example/integration_test/test_config.dart @@ -0,0 +1,19 @@ +/// Test credentials for integration tests. +/// +/// shopId / productId / searchQuery taken from native SDK test suites. +/// recommendationBlockCode must be filled in manually (not stored in source). +/// Do NOT commit real production credentials to version control. +class TestConfig { + static const shopId = '357382bf66ac0ce2f1722677c59511'; + static const apiDomain = 'api.rees46.ru'; + + /// A recommender block code that exists in your test shop. + /// Not found in any native SDK source — fill in from the REES46 dashboard. + static const recommendationBlockCode = 'your_block_code'; + + /// A search query that returns at least one result in your test shop. + static const searchQuery = 'пудра-бронзер'; + + /// A product ID that exists in your test shop. + static const productId = '486'; +} diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..5612959 --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,47 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + target 'RunnerUITests' do + inherit! :complete + end + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock new file mode 100644 index 0000000..ac25e8f --- /dev/null +++ b/example/ios/Podfile.lock @@ -0,0 +1,46 @@ +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): + - Flutter + - REES46 (= 3.23.0) + - REES46 (3.23.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`) + +SPEC REPOS: + trunk: + - CocoaAsyncSocket + - REES46 + +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" + +SPEC CHECKSUMS: + CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 + personalization_flutter_sdk: 296eed2fbff082c9697c96d2176b023c173bb375 + REES46: 48ad09f18e2d75a730728c7e10f0328ffac69b08 + +PODFILE CHECKSUM: b6e248ac4c1eff5807c8b044b39b8bc326af3f5f + +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..72e3478 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,889 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A7B83A61A5E2A2634D4B68DD /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 20B4AA99524AAD3578A54D95 /* Pods_Runner.framework */; }; + AA11BB22CC33DD4400EEFF01 /* RunnerUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD4400EEFF00 /* RunnerUITests.m */; }; + B632BB76B3DE033E33C3AF7C /* Pods_Runner_RunnerUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 96DA490D0CB17C99E25655CC /* Pods_Runner_RunnerUITests.framework */; }; + D1AEE439B64D48FFC231B7FD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 101FDAA560BF8F521BAB8014 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 101FDAA560BF8F521BAB8014 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1DF40DFCE9121725D0BC8456 /* Pods-Runner-RunnerUITests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.profile.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.profile.xcconfig"; sourceTree = ""; }; + 20B4AA99524AAD3578A54D95 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 25281CD9083CDCD536709C9E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 3059447129043636022194F8 /* Pods-Runner-RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.release.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.release.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 47A6BF8207A65A857E1F9E2C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 6BF98BB0E22B7F76759C021D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 86D4BDD5ECC7DC2C16E64E8C /* Pods-Runner-RunnerUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.debug.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.debug.xcconfig"; sourceTree = ""; }; + 94AEB8B21E6E31B2E195CD5A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 96DA490D0CB17C99E25655CC /* Pods_Runner_RunnerUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner_RunnerUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA11BB22CC33DD4400EEFF00 /* RunnerUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RunnerUITests.m; sourceTree = ""; }; + AA11BB22CC33DD4400EEFF04 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AA11BB22CC33DD4400EEFF05 /* Pods-RunnerUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.debug.xcconfig"; sourceTree = ""; }; + AA11BB22CC33DD4400EEFF06 /* Pods-RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.release.xcconfig"; path = "Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.release.xcconfig"; sourceTree = ""; }; + AA11BB22CC33DD4400EEFF07 /* Pods-RunnerUITests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.profile.xcconfig"; sourceTree = ""; }; + C8A20CF582848CCD4E47E3D6 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + CF01641D7F1716455460D103 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A7B83A61A5E2A2634D4B68DD /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA11BB22CC33DD4400EEFF08 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B632BB76B3DE033E33C3AF7C /* Pods_Runner_RunnerUITests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FF9C9FE2AEF5CB467BB081EB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D1AEE439B64D48FFC231B7FD /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + AA11BB22CC33DD4400EEFF09 /* RunnerUITests */, + F36E8928AE64637CFEACB6DC /* Pods */, + E72F27077C97822DF9AF5048 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + AA11BB22CC33DD4400EEFF04 /* RunnerUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + AA11BB22CC33DD4400EEFF09 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + AA11BB22CC33DD4400EEFF00 /* RunnerUITests.m */, + ); + path = RunnerUITests; + sourceTree = ""; + }; + E72F27077C97822DF9AF5048 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 20B4AA99524AAD3578A54D95 /* Pods_Runner.framework */, + 101FDAA560BF8F521BAB8014 /* Pods_RunnerTests.framework */, + 96DA490D0CB17C99E25655CC /* Pods_Runner_RunnerUITests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + F36E8928AE64637CFEACB6DC /* Pods */ = { + isa = PBXGroup; + children = ( + 94AEB8B21E6E31B2E195CD5A /* Pods-Runner.debug.xcconfig */, + 6BF98BB0E22B7F76759C021D /* Pods-Runner.release.xcconfig */, + CF01641D7F1716455460D103 /* Pods-Runner.profile.xcconfig */, + 47A6BF8207A65A857E1F9E2C /* Pods-RunnerTests.debug.xcconfig */, + 25281CD9083CDCD536709C9E /* Pods-RunnerTests.release.xcconfig */, + C8A20CF582848CCD4E47E3D6 /* Pods-RunnerTests.profile.xcconfig */, + AA11BB22CC33DD4400EEFF05 /* Pods-RunnerUITests.debug.xcconfig */, + AA11BB22CC33DD4400EEFF06 /* Pods-RunnerUITests.release.xcconfig */, + AA11BB22CC33DD4400EEFF07 /* Pods-RunnerUITests.profile.xcconfig */, + 86D4BDD5ECC7DC2C16E64E8C /* Pods-Runner-RunnerUITests.debug.xcconfig */, + 3059447129043636022194F8 /* Pods-Runner-RunnerUITests.release.xcconfig */, + 1DF40DFCE9121725D0BC8456 /* Pods-Runner-RunnerUITests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + B1B2B2F47EA02153B0B5E6BC /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + FF9C9FE2AEF5CB467BB081EB /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 19A191DE9373D7C8F4407517 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A99D90DAE83D5169298658CC /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; + AA11BB22CC33DD4400EEFF0B /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA11BB22CC33DD4400EEFF0C /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + 9A77EDE915878325A4720B12 /* [CP] Check Pods Manifest.lock */, + AA11BB22CC33DD4400EEFF0A /* Sources */, + AA11BB22CC33DD4400EEFF08 /* Frameworks */, + 4027F5FAFA76DC5F18F79BD3 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = AA11BB22CC33DD4400EEFF04 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + AA11BB22CC33DD4400EEFF0B /* RunnerUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 19A191DE9373D7C8F4407517 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 4027F5FAFA76DC5F18F79BD3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + 9A77EDE915878325A4720B12 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-RunnerUITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + A99D90DAE83D5169298658CC /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + B1B2B2F47EA02153B0B5E6BC /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA11BB22CC33DD4400EEFF0A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA11BB22CC33DD4400EEFF01 /* RunnerUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2CCD47244M; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 47A6BF8207A65A857E1F9E2C /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 25281CD9083CDCD536709C9E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C8A20CF582848CCD4E47E3D6 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2CCD47244M; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2CCD47244M; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + AA11BB22CC33DD4400EEFF10 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 86D4BDD5ECC7DC2C16E64E8C /* Pods-Runner-RunnerUITests.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + AA11BB22CC33DD4400EEFF11 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3059447129043636022194F8 /* Pods-Runner-RunnerUITests.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Release; + }; + AA11BB22CC33DD4400EEFF12 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1DF40DFCE9121725D0BC8456 /* Pods-Runner-RunnerUITests.profile.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rees46.rees46FlutterSdkExample.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Profile; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA11BB22CC33DD4400EEFF0C /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA11BB22CC33DD4400EEFF10 /* Debug */, + AA11BB22CC33DD4400EEFF11 /* Release */, + AA11BB22CC33DD4400EEFF12 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..4df9df3 --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/GoogleService-Info.plist b/example/ios/Runner/GoogleService-Info.plist new file mode 100644 index 0000000..39e6d39 --- /dev/null +++ b/example/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCsejvqheiopTD9dsScnVro8sTDcX57dHI + GCM_SENDER_ID + 605730184710 + PLIST_VERSION + 1 + BUNDLE_ID + com.rees46.rees46FlutterSdkExample + PROJECT_ID + rees46-com + STORAGE_BUCKET + rees46-com.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:605730184710:ios:0a0e45689eec55945b6d87 + + \ No newline at end of file diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..e80a01c --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Rees46 Flutter Sdk + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + rees46_flutter_sdk_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/Runner/SceneDelegate.swift b/example/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/example/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..2d79764 --- /dev/null +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,7 @@ +import XCTest + +final class RunnerTests: XCTestCase { + func testPlaceholder() { + XCTAssertTrue(true) + } +} diff --git a/example/ios/RunnerUITests/RunnerUITests.m b/example/ios/RunnerUITests/RunnerUITests.m new file mode 100644 index 0000000..8989957 --- /dev/null +++ b/example/ios/RunnerUITests/RunnerUITests.m @@ -0,0 +1,5 @@ +#import +#import + +PATROL_INTEGRATION_TEST_IOS_RUNNER(RunnerUITests); + diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..2b9e36b --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,1089 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + const App({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'REES46 Flutter SDK', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + home: const InitPage(), + ); + } +} + +class InitPage extends StatefulWidget { + const InitPage({super.key}); + + @override + State createState() => _InitPageState(); +} + +enum InitState { idle, initializing, initialized, failed } + +class _InitPageState extends State { + final _sdk = PersonalizationSdk(); + + // Hardcoded demo credentials — the demo does not need editable init inputs. + static const _shopId = '357382bf66ac0ce2f1722677c59511'; + static const _apiDomain = 'api.rees46.ru'; + final _stream = defaultTargetPlatform == TargetPlatform.android + ? 'android' + : 'ios'; + + bool _enableLogs = false; + bool _autoSendPushToken = true; + bool _sendAdvertisingId = false; + bool _enableAutoPopupPresentation = true; + bool _needReInitialization = false; + + InitState _initState = InitState.idle; + String? _initError; + DateTime? _lastInitAt; + + String? _storedPushToken; + DateTime? _tokenUpdatedAt; + bool _tokenLoading = false; + + // Profile & session state + String? _sid; + String? _did; + String? _profileStatus; + + // Recommendation state + final _recBlockController = TextEditingController(); + String? _recTitle; + int? _recProductCount; + String? _recError; + bool _recLoading = false; + + // Search (full) state + final _searchQueryController = TextEditingController(); + int? _searchTotal; + String? _searchError; + bool _searchLoading = false; + + // Product info state + final _productIdController = TextEditingController(); + String? _productInfoName; + String? _productInfoError; + bool _productInfoLoading = false; + + // Products list state + int? _productsListTotal; + String? _productsListError; + bool _productsListLoading = false; + + // Search blank state + int? _searchBlankProductCount; + int? _searchBlankSuggestCount; + String? _searchBlankError; + bool _searchBlankLoading = false; + + // Search instant state + final _searchInstantQueryController = TextEditingController(); + int? _searchInstantTotal; + String? _searchInstantError; + bool _searchInstantLoading = false; + + @override + void dispose() { + _recBlockController.dispose(); + _productIdController.dispose(); + _searchQueryController.dispose(); + _searchInstantQueryController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _sdk.setPushNotificationCallbacks( + onReceived: (payload) { + // In the demo we only surface init/token state; push payloads can be added later. + }, + onDelivered: (payload) {}, + onClicked: (payload) {}, + ); + // Auto-initialize on startup — the demo uses hardcoded config, so no manual + // step is needed. The button below only re-initializes (e.g. after toggling flags). + _initialize(); + } + + Future _initialize() async { + setState(() { + _initState = InitState.initializing; + _initError = null; + }); + + try { + await _sdk.initialize( + SdkInitConfig( + shopId: _shopId, + apiDomain: _apiDomain, + stream: _stream, + enableLogs: _enableLogs, + autoSendPushToken: _autoSendPushToken, + sendAdvertisingId: _sendAdvertisingId, + enableAutoPopupPresentation: _enableAutoPopupPresentation, + needReInitialization: _needReInitialization, + ), + ); + setState(() { + _initState = InitState.initialized; + _lastInitAt = DateTime.now(); + }); + await _refreshToken(); + } on PlatformException catch (e) { + setState(() { + _initState = InitState.failed; + _initError = 'PlatformException: ${e.code} ${e.message ?? ''}'.trim(); + }); + } catch (e) { + setState(() { + _initState = InitState.failed; + _initError = 'Error: $e'; + }); + } + } + + Future _refreshToken() async { + setState(() => _tokenLoading = true); + try { + final token = await _sdk.getStoredPushToken(); + setState(() { + _storedPushToken = (token == null || token.trim().isEmpty) + ? null + : token.trim(); + _tokenUpdatedAt = DateTime.now(); + }); + } catch (e) { + // Keep token as-is; this is just a demo screen. + } finally { + setState(() => _tokenLoading = false); + } + } + + Future _getSid() async { + try { + final sid = await _sdk.getSid(); + setState(() => _sid = sid); + } catch (e) { + setState(() => _sid = 'Error: $e'); + } + } + + Future _getDid() async { + try { + final did = await _sdk.getDid(); + setState(() => _did = did ?? 'null'); + } catch (e) { + setState(() => _did = 'Error: $e'); + } + } + + Future _setProfile() async { + try { + await _sdk.setProfile( + const ProfileParams( + email: 'test@example.com', + firstName: 'Test', + gender: ProfileGender.male, + ), + ); + setState(() => _profileStatus = 'Profile set'); + } catch (e) { + setState(() => _profileStatus = 'Error: $e'); + } + } + + Future _getRecommendation() async { + final code = _recBlockController.text.trim(); + if (code.isEmpty) return; + setState(() { + _recLoading = true; + _recError = null; + _recTitle = null; + _recProductCount = null; + }); + try { + final response = await _sdk.getRecommendation(code); + setState(() { + _recTitle = response.title; + _recProductCount = response.products.length; + _recLoading = false; + }); + } catch (e) { + setState(() { + _recError = 'Error: $e'; + _recLoading = false; + }); + } + } + + Future _searchFull() async { + final query = _searchQueryController.text.trim(); + if (query.isEmpty) return; + setState(() { + _searchLoading = true; + _searchError = null; + _searchTotal = null; + }); + try { + final response = await _sdk.searchFull(query); + setState(() { + _searchTotal = response.productsTotal; + _searchLoading = false; + }); + } catch (e) { + setState(() { + _searchError = 'Error: $e'; + _searchLoading = false; + }); + } + } + + Future _getProductInfo() async { + final id = _productIdController.text.trim(); + if (id.isEmpty) return; + setState(() { + _productInfoLoading = true; + _productInfoError = null; + _productInfoName = null; + }); + try { + final product = await _sdk.getProductInfo(id); + setState(() { + _productInfoName = product.name; + _productInfoLoading = false; + }); + } catch (e) { + setState(() { + _productInfoError = 'Error: $e'; + _productInfoLoading = false; + }); + } + } + + Future _getProductsList() async { + setState(() { + _productsListLoading = true; + _productsListError = null; + _productsListTotal = null; + }); + try { + final response = await _sdk.getProductsList(); + setState(() { + _productsListTotal = response.productsTotal; + _productsListLoading = false; + }); + } catch (e) { + setState(() { + _productsListError = 'Error: $e'; + _productsListLoading = false; + }); + } + } + + Future _searchBlank() async { + setState(() { + _searchBlankLoading = true; + _searchBlankError = null; + _searchBlankProductCount = null; + _searchBlankSuggestCount = null; + }); + try { + final response = await _sdk.searchBlank(); + setState(() { + _searchBlankProductCount = response.products.length; + _searchBlankSuggestCount = response.suggests.length; + _searchBlankLoading = false; + }); + } catch (e) { + setState(() { + _searchBlankError = 'Error: $e'; + _searchBlankLoading = false; + }); + } + } + + Future _searchInstant() async { + final query = _searchInstantQueryController.text.trim(); + if (query.isEmpty) return; + setState(() { + _searchInstantLoading = true; + _searchInstantError = null; + _searchInstantTotal = null; + }); + try { + final response = await _sdk.searchInstant(query); + setState(() { + _searchInstantTotal = response.productsTotal; + _searchInstantLoading = false; + }); + } catch (e) { + setState(() { + _searchInstantError = 'Error: $e'; + _searchInstantLoading = false; + }); + } + } + + Future _demoTrackEvent() async { + if (_initState != InitState.initialized) return; + try { + await _sdk.trackEvent( + 'flutter_example', + customFields: const {'source': 'example_app'}, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('trackEvent sent'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('trackEvent failed: $e'))); + } + } + + Future _demoTrackPurchase() async { + if (_initState != InitState.initialized) return; + try { + await _sdk.trackPurchase( + orderId: 'example-order-1', + orderPrice: 99.0, + items: const [PurchaseLineItem(id: 'sku-1', amount: 1, price: 99.0)], + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('trackPurchase sent'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('trackPurchase failed: $e'))); + } + } + + Future _copyToken() async { + final token = _storedPushToken; + if (token == null || token.isEmpty) return; + await Clipboard.setData(ClipboardData(text: token)); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Token copied'))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('REES46 SDK init demo')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _InitStatusCard( + state: _initState, + error: _initError, + lastInitAt: _lastInitAt, + ), + const SizedBox(height: 12), + _PushTokenCard( + token: _storedPushToken, + updatedAt: _tokenUpdatedAt, + loading: _tokenLoading, + onRefresh: _tokenLoading ? null : _refreshToken, + onCopy: (_storedPushToken?.isNotEmpty ?? false) ? _copyToken : null, + ), + const SizedBox(height: 24), + SwitchListTile( + value: _enableLogs, + onChanged: (v) => setState(() => _enableLogs = v), + title: const Text('enableLogs (iOS)'), + ), + SwitchListTile( + value: _autoSendPushToken, + onChanged: (v) => setState(() => _autoSendPushToken = v), + title: const Text('autoSendPushToken'), + ), + SwitchListTile( + value: _sendAdvertisingId, + onChanged: (v) => setState(() => _sendAdvertisingId = v), + title: const Text('sendAdvertisingId (iOS)'), + ), + SwitchListTile( + value: _enableAutoPopupPresentation, + onChanged: (v) => setState(() => _enableAutoPopupPresentation = v), + title: const Text('enableAutoPopupPresentation (iOS)'), + ), + SwitchListTile( + value: _needReInitialization, + onChanged: (v) => setState(() => _needReInitialization = v), + title: const Text('needReInitialization'), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _initState == InitState.initializing + ? null + : _initialize, + child: Text( + _initState == InitState.initializing + ? 'Initializing…' + : 'Re-initialize', + ), + ), + const SizedBox(height: 12), + _ProfileCard( + sid: _sid, + did: _did, + profileStatus: _profileStatus, + enabled: _initState == InitState.initialized, + onGetSid: _getSid, + onGetDid: _getDid, + onSetProfile: _setProfile, + ), + const SizedBox(height: 12), + _RecommendationCard( + blockController: _recBlockController, + title: _recTitle, + productCount: _recProductCount, + error: _recError, + loading: _recLoading, + enabled: _initState == InitState.initialized, + onGet: _getRecommendation, + ), + const SizedBox(height: 12), + _ProductInfoCard( + idController: _productIdController, + productName: _productInfoName, + error: _productInfoError, + loading: _productInfoLoading, + enabled: _initState == InitState.initialized, + onGet: _getProductInfo, + ), + const SizedBox(height: 12), + _ProductsListCard( + total: _productsListTotal, + error: _productsListError, + loading: _productsListLoading, + enabled: _initState == InitState.initialized, + onGet: _getProductsList, + ), + const SizedBox(height: 12), + _SearchBlankCard( + productCount: _searchBlankProductCount, + suggestCount: _searchBlankSuggestCount, + error: _searchBlankError, + loading: _searchBlankLoading, + enabled: _initState == InitState.initialized, + onSearch: _searchBlank, + ), + const SizedBox(height: 12), + _SearchInstantCard( + queryController: _searchInstantQueryController, + total: _searchInstantTotal, + error: _searchInstantError, + loading: _searchInstantLoading, + enabled: _initState == InitState.initialized, + onSearch: _searchInstant, + ), + const SizedBox(height: 12), + _SearchCard( + queryController: _searchQueryController, + total: _searchTotal, + error: _searchError, + loading: _searchLoading, + enabled: _initState == InitState.initialized, + onSearch: _searchFull, + ), + const SizedBox(height: 24), + Text('Tracking', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Requires successful initialization above.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + OutlinedButton( + key: const Key('example_demo_track_event'), + onPressed: _initState == InitState.initialized + ? _demoTrackEvent + : null, + child: const Text('Send demo trackEvent'), + ), + const SizedBox(height: 8), + OutlinedButton( + key: const Key('example_demo_track_purchase'), + onPressed: _initState == InitState.initialized + ? _demoTrackPurchase + : null, + child: const Text('Send demo trackPurchase'), + ), + ], + ), + ); + } +} + +class _InitStatusCard extends StatelessWidget { + final InitState state; + final String? error; + final DateTime? lastInitAt; + + const _InitStatusCard({ + required this.state, + required this.error, + required this.lastInitAt, + }); + + @override + Widget build(BuildContext context) { + final statusText = switch (state) { + InitState.idle => 'Idle', + InitState.initializing => 'Initializing…', + InitState.initialized => 'Initialized', + InitState.failed => 'Failed', + }; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Initialization', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text('Status: $statusText'), + if (lastInitAt != null) + Text('Last init: ${lastInitAt!.toIso8601String()}'), + if (error != null) ...[ + const SizedBox(height: 8), + Text( + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _ProfileCard extends StatelessWidget { + final String? sid; + final String? did; + final String? profileStatus; + final bool enabled; + final VoidCallback onGetSid; + final VoidCallback onGetDid; + final VoidCallback onSetProfile; + + const _ProfileCard({ + required this.sid, + required this.did, + required this.profileStatus, + required this.enabled, + required this.onGetSid, + required this.onGetDid, + required this.onSetProfile, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Profile & Session', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton( + key: const Key('btn_get_sid'), + onPressed: enabled ? onGetSid : null, + child: const Text('Get SID'), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + key: const Key('lbl_sid'), + sid ?? '—', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton( + key: const Key('btn_get_did'), + onPressed: enabled ? onGetDid : null, + child: const Text('Get DID'), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + key: const Key('lbl_did'), + did ?? '—', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton( + key: const Key('btn_set_profile'), + onPressed: enabled ? onSetProfile : null, + child: const Text('Set Profile'), + ), + const SizedBox(width: 8), + if (profileStatus != null) + Text(key: const Key('lbl_profile_status'), profileStatus!), + ], + ), + ], + ), + ), + ); + } +} + +class _RecommendationCard extends StatelessWidget { + final TextEditingController blockController; + final String? title; + final int? productCount; + final String? error; + final bool loading; + final bool enabled; + final VoidCallback onGet; + + const _RecommendationCard({ + required this.blockController, + required this.title, + required this.productCount, + required this.error, + required this.loading, + required this.enabled, + required this.onGet, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recommendations', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + key: const Key('field_rec_block_code'), + controller: blockController, + decoration: const InputDecoration( + labelText: 'Block code', + hintText: 'e.g. main_page_2', + ), + ), + const SizedBox(height: 12), + OutlinedButton( + key: const Key('btn_get_recommendations'), + onPressed: (enabled && !loading) ? onGet : null, + child: Text(loading ? 'Loading…' : 'Get Recommendations'), + ), + if (title != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_rec_title'), 'Title: $title'), + Text(key: const Key('lbl_rec_count'), 'Products: $productCount'), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_rec_error'), + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _ProductInfoCard extends StatelessWidget { + final TextEditingController idController; + final String? productName; + final String? error; + final bool loading; + final bool enabled; + final VoidCallback onGet; + + const _ProductInfoCard({ + required this.idController, + required this.productName, + required this.error, + required this.loading, + required this.enabled, + required this.onGet, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Product Info', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + key: const Key('field_product_id'), + controller: idController, + decoration: const InputDecoration( + labelText: 'Product ID', + hintText: 'e.g. sku-123', + ), + ), + const SizedBox(height: 12), + OutlinedButton( + key: const Key('btn_get_product_info'), + onPressed: (enabled && !loading) ? onGet : null, + child: Text(loading ? 'Loading…' : 'Get Product Info'), + ), + if (productName != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_product_info_name'), + 'Name: $productName', + ), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_product_info_error'), + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _ProductsListCard extends StatelessWidget { + final int? total; + final String? error; + final bool loading; + final bool enabled; + final VoidCallback onGet; + + const _ProductsListCard({ + required this.total, + required this.error, + required this.loading, + required this.enabled, + required this.onGet, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Products List', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + OutlinedButton( + key: const Key('btn_get_products_list'), + onPressed: (enabled && !loading) ? onGet : null, + child: Text(loading ? 'Loading…' : 'Get Products List'), + ), + if (total != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_products_list_total'), 'Total: $total'), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_products_list_error'), + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _SearchBlankCard extends StatelessWidget { + final int? productCount; + final int? suggestCount; + final String? error; + final bool loading; + final bool enabled; + final VoidCallback onSearch; + + const _SearchBlankCard({ + required this.productCount, + required this.suggestCount, + required this.error, + required this.loading, + required this.enabled, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search Blank', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + OutlinedButton( + key: const Key('btn_search_blank'), + onPressed: (enabled && !loading) ? onSearch : null, + child: Text(loading ? 'Loading…' : 'Search Blank'), + ), + if (productCount != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_search_blank_products'), + 'Products: $productCount', + ), + Text( + key: const Key('lbl_search_blank_suggests'), + 'Suggests: $suggestCount', + ), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_search_blank_error'), + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _SearchInstantCard extends StatelessWidget { + final TextEditingController queryController; + final int? total; + final String? error; + final bool loading; + final bool enabled; + final VoidCallback onSearch; + + const _SearchInstantCard({ + required this.queryController, + required this.total, + required this.error, + required this.loading, + required this.enabled, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search Instant', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + key: const Key('field_search_instant_query'), + controller: queryController, + decoration: const InputDecoration( + labelText: 'Instant query', + hintText: 'e.g. pho', + ), + ), + const SizedBox(height: 12), + OutlinedButton( + key: const Key('btn_search_instant'), + onPressed: (enabled && !loading) ? onSearch : null, + child: Text(loading ? 'Searching…' : 'Search Instant'), + ), + if (total != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_search_instant_total'), 'Total: $total'), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_search_instant_error'), + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _SearchCard extends StatelessWidget { + final TextEditingController queryController; + final int? total; + final String? error; + final bool loading; + final bool enabled; + final VoidCallback onSearch; + + const _SearchCard({ + required this.queryController, + required this.total, + required this.error, + required this.loading, + required this.enabled, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Search', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + TextField( + key: const Key('field_search_query'), + controller: queryController, + decoration: const InputDecoration( + labelText: 'Search query', + hintText: 'e.g. phone', + ), + ), + const SizedBox(height: 12), + OutlinedButton( + key: const Key('btn_search'), + onPressed: (enabled && !loading) ? onSearch : null, + child: Text(loading ? 'Searching…' : 'Search'), + ), + if (total != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_search_total'), 'Total: $total'), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_search_error'), + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _PushTokenCard extends StatelessWidget { + final String? token; + final DateTime? updatedAt; + final bool loading; + final VoidCallback? onRefresh; + final VoidCallback? onCopy; + + const _PushTokenCard({ + required this.token, + required this.updatedAt, + required this.loading, + required this.onRefresh, + required this.onCopy, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Stored push token', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text(token ?? 'Not available yet'), + if (updatedAt != null) + Text('Updated: ${updatedAt!.toIso8601String()}'), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton( + onPressed: onRefresh, + child: Text(loading ? 'Refreshing…' : 'Refresh'), + ), + const SizedBox(width: 12), + FilledButton(onPressed: onCopy, child: const Text('Copy')), + ], + ), + ], + ), + ), + ); + } +} diff --git a/example/plugin_integration_test.dart b/example/plugin_integration_test.dart new file mode 100644 index 0000000..330518c --- /dev/null +++ b/example/plugin_integration_test.dart @@ -0,0 +1,24 @@ +// This is a basic Flutter integration test. +// +// Since integration tests run in a full Flutter application, they can interact +// with the host side of a plugin implementation, unlike Dart unit tests. +// +// For more information about Flutter integration tests, please see +// https://flutter.dev/to/integration-testing + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:personalization_flutter_sdk/personaclick_flutter_sdk.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('getPlatformVersion test', (WidgetTester tester) async { + final PersonaclickFlutterSdk plugin = PersonaclickFlutterSdk(); + final String? version = await plugin.getPlatformVersion(); + // The version string depends on the host platform running the test, so + // just assert that some non-empty string is returned. + expect(version?.isNotEmpty, true); + }); +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..2369271 --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,371 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dispose_scope: + dependency: transitive + description: + name: dispose_scope + sha256: "48ec38ca2631c53c4f8fa96b294c801e55c335db5e3fb9f82cede150cfe5a2af" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + patrol: + dependency: "direct dev" + description: + name: patrol + sha256: "7825a6e96a8f0755f68eec600a91a08b19bd0975488a70885b3696f6b65ffc0f" + url: "https://pub.dev" + source: hosted + version: "4.5.0" + patrol_finders: + dependency: transitive + description: + name: patrol_finders + sha256: "9970eac0669a90b20ec7e1bcaabd0475655655998068ca656f4df9f6ec84f336" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + patrol_log: + dependency: transitive + description: + name: patrol_log + sha256: a2360db165c34692665c0de146e5157887d6b584fdccca8f141f947a5acf1b2e + url: "https://pub.dev" + source: hosted + version: "0.8.0" + personalization_flutter_sdk: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.11.1 <4.0.0" + flutter: ">=3.32.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..655e0d1 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,94 @@ +name: personalization_flutter_sdk_example +description: "Demonstrates how to use the personalization_flutter_sdk plugin." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ^3.11.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + personalization_flutter_sdk: + # When depending on this package from a real application you should use: + # personalization_flutter_sdk: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + patrol: ^4.5.0 + +patrol: + app_name: personalization_flutter_sdk_example + test_directory: integration_test + android: + package_name: com.rees46.rees46_flutter_sdk_example + ios: + bundle_id: com.rees46.rees46FlutterSdkExample + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..1a8e20c --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,17 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:personalization_flutter_sdk_example/main.dart'; + +void main() { + testWidgets('Renders init demo page', (WidgetTester tester) async { + await tester.pumpWidget(const App()); + expect(find.text('REES46 SDK init demo'), findsOneWidget); + }); +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..034771f --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Classes/Rees46FlutterSdkPlugin.swift b/ios/Classes/Rees46FlutterSdkPlugin.swift new file mode 100644 index 0000000..6dc7d3d --- /dev/null +++ b/ios/Classes/Rees46FlutterSdkPlugin.swift @@ -0,0 +1,710 @@ +import Flutter +import UIKit +import REES46 +import Foundation +import UserNotifications + +public class Rees46FlutterSdkPlugin: NSObject, FlutterPlugin, FlutterApplicationLifeCycleDelegate { + static var sdk: PersonalizationSDK? + static var notificationService: NotificationServiceProtocol? + static let pushTokenKey = "rees46_flutter_push_token" + private var messenger: FlutterBinaryMessenger? + private var api: PersonalizationHostApiImpl? + private var flutterApi: PersonalizationFlutterApi? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = Rees46FlutterSdkPlugin() + instance.messenger = registrar.messenger() + instance.api = PersonalizationHostApiImpl() + instance.flutterApi = PersonalizationFlutterApi(binaryMessenger: registrar.messenger()) + PersonalizationHostApiSetup.setUp( + binaryMessenger: registrar.messenger(), + api: instance.api + ) + registrar.addApplicationDelegate(instance) + + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = instance + } + } + + public func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + UserDefaults.standard.set(deviceToken, forKey: Rees46FlutterSdkPlugin.pushTokenKey) + Rees46FlutterSdkPlugin.notificationService? + .didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: deviceToken) + } + + public func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) -> Bool { + flutterApi?.onPushReceived(payload: Self._stringPayload(userInfo)) { _ in } + + Rees46FlutterSdkPlugin.notificationService? + .didReceiveRemoteNotifications(application, didReceiveRemoteNotification: userInfo) { result, _ in + completionHandler(result) + } + return true + } +} + +@available(iOS 10.0, *) +extension Rees46FlutterSdkPlugin: UNUserNotificationCenterDelegate { + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + var payload = Self._stringPayload(response.notification.request.content.userInfo) + payload["actionIdentifier"] = response.actionIdentifier + flutterApi?.onPushClicked(payload: payload) { _ in } + completionHandler() + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + flutterApi?.onPushDelivered(payload: Self._stringPayload(notification.request.content.userInfo)) { _ in } + if #available(iOS 14.0, *) { + completionHandler([.badge, .sound, .banner, .list]) + } else { + completionHandler([.badge, .sound, .alert]) + } + } +} + +extension Rees46FlutterSdkPlugin { + fileprivate static func _stringPayload(_ userInfo: [AnyHashable: Any]) -> [String: String?] { + var result: [String: String?] = [:] + for (keyAny, value) in userInfo { + let key = String(describing: keyAny) + if let str = value as? String { + result[key] = str + } else if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: []), + let json = String(data: data, encoding: .utf8) { + result[key] = json + } else { + result[key] = String(describing: value) + } + } + return result + } +} + +final class PersonalizationHostApiImpl: PersonalizationHostApi { + func getStoredPushToken() throws -> String? { + guard let deviceToken = UserDefaults.standard.data(forKey: Rees46FlutterSdkPlugin.pushTokenKey) else { + return nil + } + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + return token.isEmpty ? nil : token + } + + func initialize(config: InitConfig, completion: @escaping (Result) -> Void) { + if config.shopId.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "shopId is required", details: nil))) + return + } + + let sdk = createPersonalizationSDK( + shopId: config.shopId, + apiDomain: config.apiDomain, + stream: config.stream, + enableLogs: config.enableLogs, + autoSendPushToken: config.autoSendPushToken, + sendAdvertisingId: config.sendAdvertisingId, + parentViewController: nil, + enableAutoPopupPresentation: config.enableAutoPopupPresentation, + needReInitialization: config.needReInitialization + ) { error in + if let error = error { + completion(.failure(PigeonError(code: "init_failed", message: String(describing: error), details: nil))) + } else { + completion(.success(())) + } + } + + Rees46FlutterSdkPlugin.sdk = sdk + + // Create notification service to receive AppDelegate callbacks (device token, remote notification). + let logger = NotificationLogger() + Rees46FlutterSdkPlugin.notificationService = NotificationService( + sdk: sdk, + notificationLogger: logger + ) + } + + func getPlatformVersion() throws -> String { + return "iOS " + UIDevice.current.systemVersion + } + + func getRecommendation(code: String, paramsJson: 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 code.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "code is required", details: nil))) + return + } + let p = parseJsonObject(paramsJson) + let itemId = p?["item_id"] as? String + let categoryId = p?["category_id"] as? String + let locations = p?["locations"] as? String + let imageSize = (p?["image_size"] as? Int).map { String($0) } + let withLocations = p?["with_locations"] as? Bool ?? false + + sdk.recommend( + blockId: code, + currentProductId: itemId, + currentCategoryId: categoryId, + locations: locations, + imageSize: imageSize, + timeOut: nil, + withLocations: withLocations, + extended: false + ) { result in + switch result { + case .success(let response): + let dict = Self._recommendationToDict(response) + guard 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 recommendation response", details: nil))) + return + } + completion(.success(json)) + case .failure(let err): + completion(.failure(PigeonError(code: "recommendation_failed", message: String(describing: err), details: nil))) + } + } + } + + private static func _recommendationToDict(_ response: RecommenderResponse) -> [String: Any] { + return [ + "title": response.title, + "recommends": response.recommended.map { _productToDict($0) }, + ] + } + + private static func _productToDict(_ p: Recommended) -> [String: Any] { + var dict: [String: Any] = [ + "id": p.id, + "name": p.name, + "brand": p.brand, + "description": p.description, + "image_url": p.imageUrl, + "picture": p.resizedImageUrl, + "image_url_resized": p.resizedImages, + "url": p.url, + "price": p.price, + "price_full": p.priceFull, + "currency": p.currency, + "sales_rate": p.salesRate, + "relative_sales_rate": p.relativeSalesRate, + "categories": p.categories.map { _categoryToDict($0) }, + ] + if let pf = p.priceFormatted { dict["price_formatted"] = pf } + if let pff = p.priceFullFormatted { dict["price_full_formatted"] = pff } + return dict + } + + private static func _categoryToDict(_ c: 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 } + return dict + } + + func getProductInfo(itemId: 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 itemId.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "itemId is required", details: nil))) + return + } + sdk.getProductInfo(id: itemId) { result in + switch result { + case .success(let product): + let dict = Self._productInfoToDict(product) + guard 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 product info", details: nil))) + return + } + completion(.success(json)) + case .failure(let err): + completion(.failure(PigeonError(code: "product_info_failed", message: String(describing: err), details: nil))) + } + } + } + + func getProductsList(paramsJson: 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 + } + let p = parseJsonObject(paramsJson) + let brands = p?["brands"] as? String + let merchants = p?["merchants"] as? String + let categories = p?["categories"] as? String + let locations = p?["locations"] as? String + let limit = p?["limit"] as? Int + let page = p?["page"] as? Int + let filters = p?["filters"] as? [String: Any] + + sdk.getProductsList( + brands: brands, + merchants: merchants, + categories: categories, + locations: locations, + limit: limit, + page: page, + filters: filters + ) { result in + switch result { + case .success(let response): + let dict = Self._productsListResponseToDict(response) + guard 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 products list response", details: nil))) + return + } + completion(.success(json)) + case .failure(let err): + completion(.failure(PigeonError(code: "products_list_failed", message: String(describing: err), details: nil))) + } + } + } + + private static func _productsListResponseToDict(_ r: ProductsListResponse) -> [String: Any] { + var dict: [String: Any] = [ + "products": r.products.map { _productInfoToDict($0) }, + "products_total": r.productsTotal, + ] + if let pr = r.priceRange { + dict["price_range"] = ["min": pr.min, "max": pr.max] + } + return dict + } + + private static func _productInfoToDict(_ p: ProductInfo) -> [String: Any] { + return [ + "id": p.id, // normalised from "uniqid" + "name": p.name, + "brand": p.brand, + "description": p.description, + "image_url": p.imageUrl, + "image_url_resized": p.resizedImages, + "url": p.url, + "price": p.price, + "price_full": p.priceFull, + "price_formatted": p.priceFormatted, + "price_full_formatted": p.priceFullFormatted, + "currency": p.currency, + "sales_rate": p.salesRate, + "relative_sales_rate": p.relativeSalesRate, + "categories": p.categories.map { c -> [String: Any] in + var d: [String: Any] = ["id": c.id, "name": c.name] + if let parent = c.parentId { d["parent_id"] = parent } + if let url = c.url { d["url"] = url } + return d + }, + ] + } + + func searchBlank(completion: @escaping (Result) -> Void) { + guard let sdk = Rees46FlutterSdkPlugin.sdk else { + completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) + return + } + sdk.searchBlank { result in + switch result { + case .success(let response): + let dict = Self._searchBlankResponseToDict(response) + guard 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 search blank response", details: nil))) + return + } + completion(.success(json)) + case .failure(let err): + completion(.failure(PigeonError(code: "search_blank_failed", message: String(describing: err), details: nil))) + } + } + } + + private static func _searchBlankResponseToDict(_ r: SearchBlankResponse) -> [String: Any] { + return [ + "products": r.products.map { _searchProductToDict($0) }, + "suggests": r.suggests.map { ["name": $0.name, "url": $0.url] }, + ] + } + + func searchInstant(query: String, paramsJson: 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 query.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "query is required", details: nil))) + return + } + let p = parseJsonObject(paramsJson) + let locations = p?["locations"] as? String + let excludedBrands = p?["excluded_brands"] as? [String] + + sdk.search( + query: query, + limit: nil, + offset: nil, + categoryLimit: nil, + brandLimit: nil, + categories: nil, + extended: nil, + sortBy: nil, + sortDir: nil, + locations: locations, + excludedMerchants: nil, + excludedBrands: excludedBrands, + brands: nil, + filters: nil, + priceMin: nil, + priceMax: nil, + colors: nil, + fashionSizes: nil, + exclude: nil, + email: nil, + timeOut: nil, + disableClarification: nil + ) { result in + switch result { + case .success(let response): + let dict = Self._searchResponseToDict(response) + guard 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 search instant response", details: nil))) + return + } + completion(.success(json)) + case .failure(let err): + completion(.failure(PigeonError(code: "search_instant_failed", message: String(describing: err), details: nil))) + } + } + } + + func searchFull(query: String, paramsJson: 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 query.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "query is required", details: nil))) + return + } + let p = parseJsonObject(paramsJson) + let limit = p?["limit"] as? Int + let page = p?["page"] as? Int + let categoryLimit = p?["category_limit"] as? Int + let brandLimit = p?["brand_limit"] as? Int + let categoriesInt = (p?["categories"] as? [String])?.compactMap { Int($0) } + let sortBy = p?["sort_by"] as? String + let sortDir = p?["sort_dir"] as? String + let locations = p?["locations"] as? String + let excludedBrands = p?["excluded_brands"] as? [String] + let brands = p?["brands"] as? String + let priceMin = p?["price_min"] as? Double + let priceMax = p?["price_max"] as? Double + let colors = p?["colors"] as? [String] + let fashionSizes = p?["fashion_sizes"] as? [String] + + sdk.search( + query: query, + limit: limit, + offset: page, + categoryLimit: categoryLimit, + brandLimit: brandLimit, + categories: categoriesInt, + extended: nil, + sortBy: sortBy, + sortDir: sortDir, + locations: locations, + excludedMerchants: nil, + excludedBrands: excludedBrands, + brands: brands, + filters: nil, + priceMin: priceMin, + priceMax: priceMax, + colors: colors, + fashionSizes: fashionSizes, + exclude: nil, + email: nil, + timeOut: nil, + disableClarification: nil + ) { result in + switch result { + case .success(let response): + let dict = Self._searchResponseToDict(response) + guard 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 search response", details: nil))) + return + } + completion(.success(json)) + case .failure(let err): + completion(.failure(PigeonError(code: "search_failed", message: String(describing: err), details: nil))) + } + } + } + + private static func _searchResponseToDict(_ r: SearchResponse) -> [String: Any] { + var dict: [String: Any] = [ + "products": r.products.map { _searchProductToDict($0) }, + "categories": r.categories.map { _searchCategoryToDict($0) }, + "products_total": r.productsTotal, + ] + if let pr = r.priceRange { + dict["price_range"] = ["min": pr.min, "max": pr.max] + } + if let locs = r.locations { + dict["locations"] = locs.map { loc -> [String: Any] in + var d: [String: Any] = ["id": loc.id, "name": loc.name] + if let t = loc.type { d["type"] = t } + return d + } + } + return dict + } + + private static func _searchProductToDict(_ p: Product) -> [String: Any] { + var dict: [String: Any] = [ + "id": p.id, + "name": p.name, + "brand": p.brand, + "description": p.description, + "image_url": p.imageUrl, + "picture": p.resizedImageUrl, + "image_url_resized": p.resizedImages, + "url": p.url, + "price": p.price, + "price_full": p.priceFull, + "price_formatted": p.priceFormatted, + "price_full_formatted": p.priceFullFormatted, + "currency": p.currency, + "sales_rate": p.salesRate, + "relative_sales_rate": p.relativeSalesRate, + ] + _ = dict // suppress unused warning; all fields set above + return dict + } + + private static func _searchCategoryToDict(_ c: 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 } + if let count = c.count { dict["count"] = count } + return dict + } + + func getSid() throws -> String { + guard let sdk = Rees46FlutterSdkPlugin.sdk else { + throw PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil) + } + return sdk.userSeance + } + + func getDid() throws -> String? { + return Rees46FlutterSdkPlugin.sdk?.deviceId + } + + func setProfile(params: ProfileParamsWire, completion: @escaping (Result) -> Void) { + guard let sdk = Rees46FlutterSdkPlugin.sdk else { + 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 + if let genderStr = params.gender { + profileData.gender = genderStr == "m" ? .male : .female + } + if let birthdayStr = params.birthday { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + profileData.birthday = fmt.date(from: birthdayStr) + } + 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 + switch result { + case .success: + completion(.success(())) + case .failure(let err): + completion(.failure(PigeonError(code: "set_profile_failed", message: String(describing: err), details: nil))) + } + } + } + + func trackEvent( + event: String, + time: Int64?, + category: String?, + label: String?, + value: Int64?, + customFieldsJson: 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 event.isEmpty { + completion( + .failure(PigeonError(code: "bad_args", message: "event is required", details: nil))) + return + } + let timeInt: Int? = time.map { Int(truncatingIfNeeded: $0) } + let valueInt: Int? = value.map { Int(truncatingIfNeeded: $0) } + let customFields = parseJsonObject(customFieldsJson) + sdk.trackEvent( + event: event, + time: timeInt, + category: category, + label: label, + value: valueInt, + customFields: customFields + ) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let err): + completion( + .failure( + PigeonError( + code: "track_event_failed", + message: String(describing: err), + details: nil))) + } + } + } + + 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 + ) { + guard let sdk = Rees46FlutterSdkPlugin.sdk else { + completion( + .failure( + PigeonError( + code: "not_initialized", + message: "SDK is not initialized", + details: nil))) + return + } + if orderId.isEmpty { + completion( + .failure(PigeonError(code: "bad_args", message: "orderId is required", details: nil))) + return + } + if items.isEmpty { + completion( + .failure(PigeonError(code: "bad_args", message: "items must be non-empty", details: nil))) + return + } + let itemRequests: [PurchaseItemRequest] = items.map { wire in + PurchaseItemRequest( + id: wire.id, + amount: Int(wire.amount), + price: wire.price, + quantity: nil, + lineId: wire.lineId, + fashionSize: wire.fashionSize + ) + } + let request = PurchaseTrackingRequest( + orderId: orderId, + orderPrice: orderPrice, + items: itemRequests, + deliveryType: deliveryType, + deliveryAddress: deliveryAddress, + paymentType: paymentType, + isTaxFree: isTaxFree, + promocode: promocode, + orderCash: orderCash, + orderBonuses: orderBonuses, + orderDelivery: orderDelivery, + orderDiscount: orderDiscount, + channel: channel, + custom: parseJsonObject(customJson), + recommendedSource: parseJsonObject(recommendedSourceJson), + stream: stream, + segment: segment + ) + sdk.trackPurchase(request, recommendedBy: nil) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let err): + completion( + .failure( + PigeonError( + code: "track_purchase_failed", + message: String(describing: err), + details: nil))) + } + } + } + + private func parseJsonObject(_ json: String?) -> [String: Any]? { + guard let json, !json.isEmpty, let data = json.data(using: .utf8) else { return nil } + let obj = try? JSONSerialization.jsonObject(with: data) + return obj as? [String: Any] + } +} diff --git a/ios/Classes/pigeon/PersonalizationApi.g.swift b/ios/Classes/pigeon/PersonalizationApi.g.swift new file mode 100644 index 0000000..76222a7 --- /dev/null +++ b/ios/Classes/pigeon/PersonalizationApi.g.swift @@ -0,0 +1,769 @@ +// Autogenerated from Pigeon (v25.5.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsPersonalizationApi(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsPersonalizationApi(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsPersonalizationApi(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashPersonalizationApi(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashPersonalizationApi(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashPersonalizationApi(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct InitConfig: Hashable { + var shopId: String + var apiDomain: String + var stream: String + var enableLogs: Bool + var autoSendPushToken: Bool + var sendAdvertisingId: Bool + var enableAutoPopupPresentation: Bool + var needReInitialization: Bool + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> InitConfig? { + let shopId = pigeonVar_list[0] as! String + let apiDomain = pigeonVar_list[1] as! String + let stream = pigeonVar_list[2] as! String + let enableLogs = pigeonVar_list[3] as! Bool + let autoSendPushToken = pigeonVar_list[4] as! Bool + let sendAdvertisingId = pigeonVar_list[5] as! Bool + let enableAutoPopupPresentation = pigeonVar_list[6] as! Bool + let needReInitialization = pigeonVar_list[7] as! Bool + + return InitConfig( + shopId: shopId, + apiDomain: apiDomain, + stream: stream, + enableLogs: enableLogs, + autoSendPushToken: autoSendPushToken, + sendAdvertisingId: sendAdvertisingId, + enableAutoPopupPresentation: enableAutoPopupPresentation, + needReInitialization: needReInitialization + ) + } + func toList() -> [Any?] { + return [ + shopId, + apiDomain, + stream, + enableLogs, + autoSendPushToken, + sendAdvertisingId, + enableAutoPopupPresentation, + needReInitialization, + ] + } + static func == (lhs: InitConfig, rhs: InitConfig) -> Bool { + return deepEqualsPersonalizationApi(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashPersonalizationApi(value: toList(), hasher: &hasher) + } +} + +/// Wire format for one purchase line (maps to native [PurchaseItemRequest]; quantity not exposed). +/// +/// Generated class from Pigeon that represents data sent in messages. +struct PurchaseLineItemWire: Hashable { + var id: String + var amount: Int64 + var price: Double + var lineId: String? = nil + var fashionSize: String? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> PurchaseLineItemWire? { + let id = pigeonVar_list[0] as! String + let amount = pigeonVar_list[1] as! Int64 + let price = pigeonVar_list[2] as! Double + let lineId: String? = nilOrValue(pigeonVar_list[3]) + let fashionSize: String? = nilOrValue(pigeonVar_list[4]) + + return PurchaseLineItemWire( + id: id, + amount: amount, + price: price, + lineId: lineId, + fashionSize: fashionSize + ) + } + func toList() -> [Any?] { + return [ + id, + amount, + price, + lineId, + fashionSize, + ] + } + static func == (lhs: PurchaseLineItemWire, rhs: PurchaseLineItemWire) -> Bool { + return deepEqualsPersonalizationApi(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashPersonalizationApi(value: toList(), hasher: &hasher) + } +} + +/// Wire format for profile fields sent to native SDK. +/// All fields are optional — only non-null values are forwarded. +/// [birthday] must be a "yyyy-MM-dd" string. +/// [gender] must be "m" or "f". +/// [customPropertiesJson] is a JSON object string or null. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ProfileParamsWire: Hashable { + var email: String? = nil + var phone: String? = nil + var loyaltyId: String? = nil + var firstName: String? = nil + var lastName: String? = nil + var birthday: String? = nil + var age: Int64? = nil + var gender: String? = nil + var location: String? = nil + var advertisingId: String? = nil + var fbId: String? = nil + var vkId: String? = nil + var telegramId: String? = nil + var loyaltyCardLocation: String? = nil + var loyaltyStatus: String? = nil + var loyaltyBonuses: Int64? = nil + var loyaltyBonusesToNextLevel: Int64? = nil + var boughtSomething: Bool? = nil + var userId: String? = nil + var customPropertiesJson: String? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ProfileParamsWire? { + let email: String? = nilOrValue(pigeonVar_list[0]) + let phone: String? = nilOrValue(pigeonVar_list[1]) + let loyaltyId: String? = nilOrValue(pigeonVar_list[2]) + let firstName: String? = nilOrValue(pigeonVar_list[3]) + let lastName: String? = nilOrValue(pigeonVar_list[4]) + let birthday: String? = nilOrValue(pigeonVar_list[5]) + let age: Int64? = nilOrValue(pigeonVar_list[6]) + let gender: String? = nilOrValue(pigeonVar_list[7]) + let location: String? = nilOrValue(pigeonVar_list[8]) + let advertisingId: String? = nilOrValue(pigeonVar_list[9]) + let fbId: String? = nilOrValue(pigeonVar_list[10]) + let vkId: String? = nilOrValue(pigeonVar_list[11]) + let telegramId: String? = nilOrValue(pigeonVar_list[12]) + let loyaltyCardLocation: String? = nilOrValue(pigeonVar_list[13]) + let loyaltyStatus: String? = nilOrValue(pigeonVar_list[14]) + let loyaltyBonuses: Int64? = nilOrValue(pigeonVar_list[15]) + let loyaltyBonusesToNextLevel: Int64? = nilOrValue(pigeonVar_list[16]) + let boughtSomething: Bool? = nilOrValue(pigeonVar_list[17]) + let userId: String? = nilOrValue(pigeonVar_list[18]) + let customPropertiesJson: String? = nilOrValue(pigeonVar_list[19]) + + return ProfileParamsWire( + email: email, + phone: phone, + loyaltyId: loyaltyId, + firstName: firstName, + lastName: lastName, + birthday: birthday, + age: age, + gender: gender, + location: location, + advertisingId: advertisingId, + fbId: fbId, + vkId: vkId, + telegramId: telegramId, + loyaltyCardLocation: loyaltyCardLocation, + loyaltyStatus: loyaltyStatus, + loyaltyBonuses: loyaltyBonuses, + loyaltyBonusesToNextLevel: loyaltyBonusesToNextLevel, + boughtSomething: boughtSomething, + userId: userId, + customPropertiesJson: customPropertiesJson + ) + } + func toList() -> [Any?] { + return [ + email, + phone, + loyaltyId, + firstName, + lastName, + birthday, + age, + gender, + location, + advertisingId, + fbId, + vkId, + telegramId, + loyaltyCardLocation, + loyaltyStatus, + loyaltyBonuses, + loyaltyBonusesToNextLevel, + boughtSomething, + userId, + customPropertiesJson, + ] + } + static func == (lhs: ProfileParamsWire, rhs: ProfileParamsWire) -> Bool { + return deepEqualsPersonalizationApi(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashPersonalizationApi(value: toList(), hasher: &hasher) + } +} + +private class PersonalizationApiPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return InitConfig.fromList(self.readValue() as! [Any?]) + case 130: + return PurchaseLineItemWire.fromList(self.readValue() as! [Any?]) + case 131: + return ProfileParamsWire.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class PersonalizationApiPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? InitConfig { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? PurchaseLineItemWire { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? ProfileParamsWire { + super.writeByte(131) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class PersonalizationApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return PersonalizationApiPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return PersonalizationApiPigeonCodecWriter(data: data) + } +} + +class PersonalizationApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = PersonalizationApiPigeonCodec(readerWriter: PersonalizationApiPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol PersonalizationHostApi { + func initialize(config: InitConfig, completion: @escaping (Result) -> Void) + func getPlatformVersion() throws -> String + /// Returns the push token stored by the native SDK (if any). + func getStoredPushToken() throws -> String? + /// [customFieldsJson] is JSON object string or null (maps to native custom fields map). + func trackEvent(event: String, time: Int64?, category: String?, label: String?, value: Int64?, customFieldsJson: String?, completion: @escaping (Result) -> Void) + func setProfile(params: ProfileParamsWire, completion: @escaping (Result) -> Void) + /// Returns the recommendation block as a JSON string. + /// [paramsJson] is a JSON object string with optional filter parameters. + /// Dart layer parses the result into [RecommendationResponse]. + func getRecommendation(code: String, paramsJson: String?, completion: @escaping (Result) -> Void) + /// Returns the current session ID from the native SDK. + func getSid() throws -> String + /// Returns the device ID assigned by the native SDK, or null before first sync. + func getDid() throws -> String? + /// Returns a single product's details as a JSON string. + /// Dart layer parses the result into [Product]. + func getProductInfo(itemId: String, completion: @escaping (Result) -> Void) + /// Returns a paginated product catalog list as a JSON string. + /// [paramsJson] is a JSON object with optional filter fields. + /// Dart layer parses the result into [ProductsListResponse]. + func getProductsList(paramsJson: String?, completion: @escaping (Result) -> Void) + /// Returns blank search results (trending/popular) as a JSON string. + /// No parameters — the native SDK decides what to return based on shop config. + /// Dart layer parses the result into [SearchBlankResponse]. + func searchBlank(completion: @escaping (Result) -> Void) + /// Returns instant (typeahead) search results as a JSON string. + /// [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). + /// Dart layer parses the result into [SearchInstantResponse]. + func searchInstant(query: String, paramsJson: String?, completion: @escaping (Result) -> Void) + /// Returns full search results as a JSON string. + /// [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) + /// [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) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class PersonalizationHostApiSetup { + static var codec: FlutterStandardMessageCodec { PersonalizationApiPigeonCodec.shared } + /// Sets up an instance of `PersonalizationHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PersonalizationHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let initializeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.initialize\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + initializeChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let configArg = args[0] as! InitConfig + api.initialize(config: configArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + initializeChannel.setMessageHandler(nil) + } + let getPlatformVersionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getPlatformVersion\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getPlatformVersionChannel.setMessageHandler { _, reply in + do { + let result = try api.getPlatformVersion() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getPlatformVersionChannel.setMessageHandler(nil) + } + /// Returns the push token stored by the native SDK (if any). + let getStoredPushTokenChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getStoredPushToken\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getStoredPushTokenChannel.setMessageHandler { _, reply in + do { + let result = try api.getStoredPushToken() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getStoredPushTokenChannel.setMessageHandler(nil) + } + /// [customFieldsJson] is JSON object string or null (maps to native custom fields map). + let trackEventChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackEvent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + trackEventChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let eventArg = args[0] as! String + let timeArg: Int64? = nilOrValue(args[1]) + let categoryArg: String? = nilOrValue(args[2]) + let labelArg: String? = nilOrValue(args[3]) + let valueArg: Int64? = nilOrValue(args[4]) + let customFieldsJsonArg: String? = nilOrValue(args[5]) + api.trackEvent(event: eventArg, time: timeArg, category: categoryArg, label: labelArg, value: valueArg, customFieldsJson: customFieldsJsonArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + trackEventChannel.setMessageHandler(nil) + } + let setProfileChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.setProfile\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setProfileChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let paramsArg = args[0] as! ProfileParamsWire + api.setProfile(params: paramsArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + setProfileChannel.setMessageHandler(nil) + } + /// Returns the recommendation block as a JSON string. + /// [paramsJson] is a JSON object string with optional filter parameters. + /// Dart layer parses the result into [RecommendationResponse]. + let getRecommendationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getRecommendation\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getRecommendationChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let codeArg = args[0] as! String + let paramsJsonArg: String? = nilOrValue(args[1]) + api.getRecommendation(code: codeArg, paramsJson: paramsJsonArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getRecommendationChannel.setMessageHandler(nil) + } + /// Returns the current session ID from the native SDK. + let getSidChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getSidChannel.setMessageHandler { _, reply in + do { + let result = try api.getSid() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getSidChannel.setMessageHandler(nil) + } + /// Returns the device ID assigned by the native SDK, or null before first sync. + let getDidChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getDidChannel.setMessageHandler { _, reply in + do { + let result = try api.getDid() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getDidChannel.setMessageHandler(nil) + } + /// Returns a single product's details as a JSON string. + /// Dart layer parses the result into [Product]. + let getProductInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getProductInfoChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let itemIdArg = args[0] as! String + api.getProductInfo(itemId: itemIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getProductInfoChannel.setMessageHandler(nil) + } + /// Returns a paginated product catalog list as a JSON string. + /// [paramsJson] is a JSON object with optional filter fields. + /// Dart layer parses the result into [ProductsListResponse]. + let getProductsListChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductsList\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getProductsListChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let paramsJsonArg: String? = nilOrValue(args[0]) + api.getProductsList(paramsJson: paramsJsonArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getProductsListChannel.setMessageHandler(nil) + } + /// Returns blank search results (trending/popular) as a JSON string. + /// No parameters — the native SDK decides what to return based on shop config. + /// Dart layer parses the result into [SearchBlankResponse]. + let searchBlankChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchBlank\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + searchBlankChannel.setMessageHandler { _, reply in + api.searchBlank { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + searchBlankChannel.setMessageHandler(nil) + } + /// Returns instant (typeahead) search results as a JSON string. + /// [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). + /// Dart layer parses the result into [SearchInstantResponse]. + let searchInstantChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchInstant\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + searchInstantChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let queryArg = args[0] as! String + let paramsJsonArg: String? = nilOrValue(args[1]) + api.searchInstant(query: queryArg, paramsJson: paramsJsonArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + searchInstantChannel.setMessageHandler(nil) + } + /// Returns full search results as a JSON string. + /// [paramsJson] is a JSON object string with optional search parameters. + /// Dart layer parses the result into [SearchFullResponse]. + let searchFullChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + searchFullChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let queryArg = args[0] as! String + let paramsJsonArg: String? = nilOrValue(args[1]) + api.searchFull(query: queryArg, paramsJson: paramsJsonArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + searchFullChannel.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 { + trackPurchaseChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let orderIdArg = args[0] as! String + let orderPriceArg = args[1] as! Double + let itemsArg = args[2] as! [PurchaseLineItemWire] + let deliveryTypeArg: String? = nilOrValue(args[3]) + let deliveryAddressArg: String? = nilOrValue(args[4]) + let paymentTypeArg: String? = nilOrValue(args[5]) + let isTaxFreeArg = args[6] as! Bool + let promocodeArg: String? = nilOrValue(args[7]) + let orderCashArg: Double? = nilOrValue(args[8]) + let orderBonusesArg: Double? = nilOrValue(args[9]) + let orderDeliveryArg: Double? = nilOrValue(args[10]) + let orderDiscountArg: Double? = nilOrValue(args[11]) + let channelArg: String? = nilOrValue(args[12]) + let customJsonArg: String? = nilOrValue(args[13]) + let recommendedSourceJsonArg: String? = nilOrValue(args[14]) + let streamArg: String? = nilOrValue(args[15]) + let segmentArg: String? = nilOrValue(args[16]) + api.trackPurchase(orderId: orderIdArg, orderPrice: orderPriceArg, items: itemsArg, deliveryType: deliveryTypeArg, deliveryAddress: deliveryAddressArg, paymentType: paymentTypeArg, isTaxFree: isTaxFreeArg, promocode: promocodeArg, orderCash: orderCashArg, orderBonuses: orderBonusesArg, orderDelivery: orderDeliveryArg, orderDiscount: orderDiscountArg, channel: channelArg, customJson: customJsonArg, recommendedSourceJson: recommendedSourceJsonArg, stream: streamArg, segment: segmentArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + trackPurchaseChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol PersonalizationFlutterApiProtocol { + func onPushReceived(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) + func onPushDelivered(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) + func onPushClicked(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) +} +class PersonalizationFlutterApi: PersonalizationFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: PersonalizationApiPigeonCodec { + return PersonalizationApiPigeonCodec.shared + } + func onPushReceived(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([payloadArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func onPushDelivered(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([payloadArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func onPushClicked(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([payloadArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } +} diff --git a/ios/Resources/PrivacyInfo.xcprivacy b/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..a34b7e2 --- /dev/null +++ b/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/ios/personalization_flutter_sdk.podspec b/ios/personalization_flutter_sdk.podspec new file mode 100644 index 0000000..d384d38 --- /dev/null +++ b/ios/personalization_flutter_sdk.podspec @@ -0,0 +1,27 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# +Pod::Spec.new do |s| + s.name = 'personalization_flutter_sdk' + s.version = '0.0.1' + s.summary = 'Flutter plugin wrapper around REES46 native SDK.' + s.description = <<-DESC +Flutter plugin wrapper around REES46 native SDK. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'REES46' => 'support@rees46.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.dependency 'REES46', '3.23.0' + s.platform = :ios, '13.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' + } + s.swift_version = '5.0' +end + diff --git a/lib/personaclick_flutter_sdk.dart b/lib/personaclick_flutter_sdk.dart new file mode 100644 index 0000000..721c987 --- /dev/null +++ b/lib/personaclick_flutter_sdk.dart @@ -0,0 +1,9 @@ +import 'src/personalization_sdk.dart'; +import 'src/sdk_init_config.dart'; + +export 'src/sdk_init_config.dart' show SdkInitConfig; +export 'src/tracking/purchase_line_item.dart' show PurchaseLineItem; + +typedef PersonaclickInitConfig = SdkInitConfig; + +class PersonaclickFlutterSdk extends PersonalizationSdk {} diff --git a/lib/personalization_flutter_sdk.dart b/lib/personalization_flutter_sdk.dart new file mode 100644 index 0000000..29cb996 --- /dev/null +++ b/lib/personalization_flutter_sdk.dart @@ -0,0 +1,10 @@ +export 'src/personalization_sdk.dart'; +export 'src/profile/profile_params.dart'; +export 'src/recommendation/recommendation_params.dart'; +export 'src/recommendation/recommendation_response.dart'; +export 'src/products/products_list_params.dart'; +export 'src/products/products_list_response.dart'; +export 'src/search/search_params.dart'; +export 'src/search/search_response.dart'; +export 'src/sdk_init_config.dart'; +export 'src/tracking/purchase_line_item.dart'; diff --git a/lib/rees46_flutter_sdk_method_channel.dart b/lib/rees46_flutter_sdk_method_channel.dart new file mode 100644 index 0000000..43a95a6 --- /dev/null +++ b/lib/rees46_flutter_sdk_method_channel.dart @@ -0,0 +1 @@ +export 'src/pigeon/personalization_api.g.dart' show PersonalizationHostApi; diff --git a/lib/rees46_flutter_sdk_platform_interface.dart b/lib/rees46_flutter_sdk_platform_interface.dart new file mode 100644 index 0000000..5480116 --- /dev/null +++ b/lib/rees46_flutter_sdk_platform_interface.dart @@ -0,0 +1 @@ +export 'src/sdk_init_config.dart' show SdkInitConfig; diff --git a/lib/src/init/sdk_init_handler.dart b/lib/src/init/sdk_init_handler.dart new file mode 100644 index 0000000..124c943 --- /dev/null +++ b/lib/src/init/sdk_init_handler.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; + +import '../pigeon/personalization_api.g.dart' as pigeon; +import '../sdk_init_config.dart'; + +class SdkInitHandler { + final pigeon.PersonalizationHostApi _api; + + SdkInitHandler({pigeon.PersonalizationHostApi? api}) + : _api = api ?? pigeon.PersonalizationHostApi(); + + Future initialize(SdkInitConfig config) { + final apiDomain = + (config.apiDomain == null || config.apiDomain!.trim().isEmpty) + ? 'api.rees46.ru' + : config.apiDomain!.trim(); + + final stream = (config.stream == null || config.stream!.trim().isEmpty) + ? _defaultStream() + : config.stream!.trim(); + + final enableLogs = config.enableLogs ?? false; + final autoSendPushToken = config.autoSendPushToken ?? true; + final sendAdvertisingId = config.sendAdvertisingId ?? false; + final enableAutoPopupPresentation = + config.enableAutoPopupPresentation ?? true; + final needReInitialization = config.needReInitialization ?? false; + + return _api.initialize( + pigeon.InitConfig( + shopId: config.shopId, + apiDomain: apiDomain, + stream: stream, + enableLogs: enableLogs, + autoSendPushToken: autoSendPushToken, + sendAdvertisingId: sendAdvertisingId, + enableAutoPopupPresentation: enableAutoPopupPresentation, + needReInitialization: needReInitialization, + ), + ); + } + + static String _defaultStream() { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return 'android'; + case TargetPlatform.iOS: + return 'ios'; + default: + return 'android'; + } + } +} diff --git a/lib/src/method_channel.dart b/lib/src/method_channel.dart new file mode 100644 index 0000000..7ffd13d --- /dev/null +++ b/lib/src/method_channel.dart @@ -0,0 +1 @@ +// Pigeon is used for platform communication. diff --git a/lib/src/personalization_sdk.dart b/lib/src/personalization_sdk.dart new file mode 100644 index 0000000..be5924c --- /dev/null +++ b/lib/src/personalization_sdk.dart @@ -0,0 +1,209 @@ +import 'dart:convert'; + +import 'pigeon/personalization_api.g.dart' as pigeon; +import 'init/sdk_init_handler.dart'; +import 'profile/profile_params.dart'; +import 'push/push_notification_callbacks.dart'; +import 'recommendation/recommendation_params.dart'; +import 'recommendation/recommendation_response.dart'; +import 'products/products_list_params.dart'; +import 'products/products_list_response.dart'; +import 'search/search_params.dart'; +import 'search/search_response.dart'; +import 'sdk_init_config.dart'; +import 'tracking/purchase_line_item.dart'; + +class PersonalizationSdk { + final pigeon.PersonalizationHostApi _api; + final SdkInitHandler _initHandler; + final PushNotificationCallbacks _pushCallbacks = PushNotificationCallbacks(); + + PersonalizationSdk({pigeon.PersonalizationHostApi? api}) + : _api = api ?? pigeon.PersonalizationHostApi(), + _initHandler = SdkInitHandler(api: api) { + pigeon.PersonalizationFlutterApi.setUp(_pushCallbacks); + } + + /// Registers optional listeners for push lifecycle events emitted by native code. + void setPushNotificationCallbacks({ + void Function(Map payload)? onReceived, + void Function(Map payload)? onDelivered, + void Function(Map payload)? onClicked, + }) { + _pushCallbacks.setCallbacks( + onReceived: onReceived, + onDelivered: onDelivered, + onClicked: onClicked, + ); + } + + Future getPlatformVersion() { + return _api.getPlatformVersion(); + } + + Future getStoredPushToken() { + return _api.getStoredPushToken(); + } + + Future initialize(SdkInitConfig config) { + return _initHandler.initialize(config); + } + + Future setProfile(ProfileParams params) { + return _api.setProfile(params.toWire()); + } + + Future getSid() { + return _api.getSid(); + } + + Future getDid() { + return _api.getDid(); + } + + Future getProductInfo(String itemId) async { + if (itemId.isEmpty) { + throw ArgumentError.value(itemId, 'itemId', 'must be non-empty'); + } + final json = await _api.getProductInfo(itemId); + return Product.fromJson(jsonDecode(json) as Map); + } + + Future getProductsList({ + ProductsListParams? params, + }) async { + final json = await _api.getProductsList(params?.toJson()); + return ProductsListResponse.fromJson( + jsonDecode(json) as Map, + ); + } + + Future searchBlank() async { + final json = await _api.searchBlank(); + return SearchBlankResponse.fromJson( + jsonDecode(json) as Map, + ); + } + + Future searchInstant( + String query, { + SearchInstantParams? params, + }) async { + if (query.isEmpty) { + throw ArgumentError.value(query, 'query', 'must be non-empty'); + } + final json = await _api.searchInstant(query, params?.toJson()); + return SearchInstantResponse.fromJson( + jsonDecode(json) as Map, + ); + } + + Future searchFull( + String query, { + SearchParams? params, + }) async { + if (query.isEmpty) { + throw ArgumentError.value(query, 'query', 'must be non-empty'); + } + final json = await _api.searchFull(query, params?.toJson()); + return SearchFullResponse.fromJson( + jsonDecode(json) as Map, + ); + } + + Future getRecommendation( + String code, { + RecommendationParams? params, + }) async { + if (code.isEmpty) { + throw ArgumentError.value(code, 'code', 'must be non-empty'); + } + final json = await _api.getRecommendation(code, params?.toJson()); + return RecommendationResponse.fromJson( + jsonDecode(json) as Map, + ); + } + + /// Custom event tracking (native `trackEvent` / `TrackEventManager.trackEvent`). + Future trackEvent( + String event, { + int? time, + String? category, + String? label, + int? value, + Map? customFields, + }) { + if (event.isEmpty) { + throw ArgumentError.value(event, 'event', 'must be non-empty'); + } + final customFieldsJson = customFields == null + ? null + : jsonEncode(customFields); + return _api.trackEvent( + event, + time, + category, + label, + value, + customFieldsJson, + ); + } + + /// Purchase tracking (native `trackPurchase` with typed line items). + Future trackPurchase({ + required String orderId, + required double orderPrice, + required List items, + String? deliveryType, + String? deliveryAddress, + String? paymentType, + bool isTaxFree = false, + String? promocode, + double? orderCash, + double? orderBonuses, + double? orderDelivery, + double? orderDiscount, + String? channel, + Map? custom, + Map? recommendedSource, + String? stream, + String? segment, + }) { + if (orderId.isEmpty) { + throw ArgumentError.value(orderId, 'orderId', 'must be non-empty'); + } + if (items.isEmpty) { + throw ArgumentError.value(items, 'items', 'must be non-empty'); + } + final wireItems = items + .map( + (e) => pigeon.PurchaseLineItemWire( + id: e.id, + amount: e.amount, + price: e.price, + lineId: e.lineId, + fashionSize: e.fashionSize, + ), + ) + .toList(); + return _api.trackPurchase( + orderId, + orderPrice, + wireItems, + deliveryType, + deliveryAddress, + paymentType, + isTaxFree, + promocode, + orderCash, + orderBonuses, + orderDelivery, + orderDiscount, + channel, + custom == null ? null : jsonEncode(custom), + recommendedSource == null ? null : jsonEncode(recommendedSource), + stream, + segment, + ); + } +} diff --git a/lib/src/pigeon/personalization_api.g.dart b/lib/src/pigeon/personalization_api.g.dart new file mode 100644 index 0000000..2d2afc9 --- /dev/null +++ b/lib/src/pigeon/personalization_api.g.dart @@ -0,0 +1,972 @@ +// Autogenerated from Pigeon (v25.5.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse({ + Object? result, + PlatformException? error, + bool empty = false, +}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed.every( + ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]), + ); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key]), + ); + } + return a == b; +} + +class InitConfig { + InitConfig({ + required this.shopId, + required this.apiDomain, + required this.stream, + required this.enableLogs, + required this.autoSendPushToken, + required this.sendAdvertisingId, + required this.enableAutoPopupPresentation, + required this.needReInitialization, + }); + + String shopId; + + String apiDomain; + + String stream; + + bool enableLogs; + + bool autoSendPushToken; + + bool sendAdvertisingId; + + bool enableAutoPopupPresentation; + + bool needReInitialization; + + List _toList() { + return [ + shopId, + apiDomain, + stream, + enableLogs, + autoSendPushToken, + sendAdvertisingId, + enableAutoPopupPresentation, + needReInitialization, + ]; + } + + Object encode() { + return _toList(); + } + + static InitConfig decode(Object result) { + result as List; + return InitConfig( + shopId: result[0]! as String, + apiDomain: result[1]! as String, + stream: result[2]! as String, + enableLogs: result[3]! as bool, + autoSendPushToken: result[4]! as bool, + sendAdvertisingId: result[5]! as bool, + enableAutoPopupPresentation: result[6]! as bool, + needReInitialization: result[7]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! InitConfig || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Wire format for one purchase line (maps to native [PurchaseItemRequest]; quantity not exposed). +class PurchaseLineItemWire { + PurchaseLineItemWire({ + required this.id, + required this.amount, + required this.price, + this.lineId, + this.fashionSize, + }); + + String id; + + int amount; + + double price; + + String? lineId; + + String? fashionSize; + + List _toList() { + return [id, amount, price, lineId, fashionSize]; + } + + Object encode() { + return _toList(); + } + + static PurchaseLineItemWire decode(Object result) { + result as List; + return PurchaseLineItemWire( + id: result[0]! as String, + amount: result[1]! as int, + price: result[2]! as double, + lineId: result[3] as String?, + fashionSize: result[4] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PurchaseLineItemWire || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Wire format for profile fields sent to native SDK. +/// All fields are optional — only non-null values are forwarded. +/// [birthday] must be a "yyyy-MM-dd" string. +/// [gender] must be "m" or "f". +/// [customPropertiesJson] is a JSON object string or null. +class ProfileParamsWire { + ProfileParamsWire({ + this.email, + this.phone, + this.loyaltyId, + this.firstName, + this.lastName, + this.birthday, + this.age, + this.gender, + this.location, + this.advertisingId, + this.fbId, + this.vkId, + this.telegramId, + this.loyaltyCardLocation, + this.loyaltyStatus, + this.loyaltyBonuses, + this.loyaltyBonusesToNextLevel, + this.boughtSomething, + this.userId, + this.customPropertiesJson, + }); + + String? email; + + String? phone; + + String? loyaltyId; + + String? firstName; + + String? lastName; + + String? birthday; + + int? age; + + String? gender; + + String? location; + + String? advertisingId; + + String? fbId; + + String? vkId; + + String? telegramId; + + String? loyaltyCardLocation; + + String? loyaltyStatus; + + int? loyaltyBonuses; + + int? loyaltyBonusesToNextLevel; + + bool? boughtSomething; + + String? userId; + + String? customPropertiesJson; + + List _toList() { + return [ + email, + phone, + loyaltyId, + firstName, + lastName, + birthday, + age, + gender, + location, + advertisingId, + fbId, + vkId, + telegramId, + loyaltyCardLocation, + loyaltyStatus, + loyaltyBonuses, + loyaltyBonusesToNextLevel, + boughtSomething, + userId, + customPropertiesJson, + ]; + } + + Object encode() { + return _toList(); + } + + static ProfileParamsWire decode(Object result) { + result as List; + return ProfileParamsWire( + email: result[0] as String?, + phone: result[1] as String?, + loyaltyId: result[2] as String?, + firstName: result[3] as String?, + lastName: result[4] as String?, + birthday: result[5] as String?, + age: result[6] as int?, + gender: result[7] as String?, + location: result[8] as String?, + advertisingId: result[9] as String?, + fbId: result[10] as String?, + vkId: result[11] as String?, + telegramId: result[12] as String?, + loyaltyCardLocation: result[13] as String?, + loyaltyStatus: result[14] as String?, + loyaltyBonuses: result[15] as int?, + loyaltyBonusesToNextLevel: result[16] as int?, + boughtSomething: result[17] as bool?, + userId: result[18] as String?, + customPropertiesJson: result[19] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ProfileParamsWire || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is InitConfig) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is PurchaseLineItemWire) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is ProfileParamsWire) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return InitConfig.decode(readValue(buffer)!); + case 130: + return PurchaseLineItemWire.decode(readValue(buffer)!); + case 131: + return ProfileParamsWire.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class PersonalizationHostApi { + /// Constructor for [PersonalizationHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + PersonalizationHostApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future initialize(InitConfig config) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.initialize$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [config], + ); + 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 { + return; + } + } + + Future getPlatformVersion() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getPlatformVersion$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + 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 push token stored by the native SDK (if any). + Future getStoredPushToken() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getStoredPushToken$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + 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 { + return (pigeonVar_replyList[0] as String?); + } + } + + /// [customFieldsJson] is JSON object string or null (maps to native custom fields map). + Future trackEvent( + String event, + int? time, + String? category, + String? label, + int? value, + String? customFieldsJson, + ) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackEvent$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [event, time, category, label, value, customFieldsJson], + ); + 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 { + return; + } + } + + Future setProfile(ProfileParamsWire params) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.setProfile$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [params], + ); + 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 { + return; + } + } + + /// Returns the recommendation block as a JSON string. + /// [paramsJson] is a JSON object string with optional filter parameters. + /// Dart layer parses the result into [RecommendationResponse]. + Future getRecommendation(String code, String? paramsJson) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getRecommendation$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [code, paramsJson], + ); + 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 current session ID from the native SDK. + Future getSid() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + 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 device ID assigned by the native SDK, or null before first sync. + Future getDid() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + 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 { + return (pigeonVar_replyList[0] as String?); + } + } + + /// Returns a single product's details as a JSON string. + /// Dart layer parses the result into [Product]. + Future getProductInfo(String itemId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductInfo$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [itemId], + ); + 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 a paginated product catalog list as a JSON string. + /// [paramsJson] is a JSON object with optional filter fields. + /// Dart layer parses the result into [ProductsListResponse]. + Future getProductsList(String? paramsJson) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductsList$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [paramsJson], + ); + 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 blank search results (trending/popular) as a JSON string. + /// No parameters — the native SDK decides what to return based on shop config. + /// Dart layer parses the result into [SearchBlankResponse]. + Future searchBlank() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchBlank$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + 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 instant (typeahead) search results as a JSON string. + /// [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). + /// Dart layer parses the result into [SearchInstantResponse]. + Future searchInstant(String query, String? paramsJson) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchInstant$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [query, paramsJson], + ); + 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 full search results as a JSON string. + /// [paramsJson] is a JSON object string with optional search parameters. + /// Dart layer parses the result into [SearchFullResponse]. + Future searchFull(String query, String? paramsJson) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [query, paramsJson], + ); + 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, + double orderPrice, + List items, + String? deliveryType, + String? deliveryAddress, + String? paymentType, + bool isTaxFree, + String? promocode, + double? orderCash, + double? orderBonuses, + double? orderDelivery, + double? orderDiscount, + String? channel, + String? customJson, + String? recommendedSourceJson, + String? stream, + String? segment, + ) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel + .send([ + orderId, + orderPrice, + items, + deliveryType, + deliveryAddress, + paymentType, + isTaxFree, + promocode, + orderCash, + orderBonuses, + orderDelivery, + orderDiscount, + channel, + customJson, + recommendedSourceJson, + stream, + segment, + ]); + 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 { + return; + } + } +} + +abstract class PersonalizationFlutterApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void onPushReceived(Map payload); + + void onPushDelivered(Map payload); + + void onPushClicked(Map payload); + + static void setUp( + PersonalizationFlutterApi? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + { + final BasicMessageChannel + pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived was null.', + ); + final List args = (message as List?)!; + final Map? arg_payload = + (args[0] as Map?)?.cast(); + assert( + arg_payload != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived was null, expected non-null Map.', + ); + try { + api.onPushReceived(arg_payload!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel + pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered was null.', + ); + final List args = (message as List?)!; + final Map? arg_payload = + (args[0] as Map?)?.cast(); + assert( + arg_payload != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered was null, expected non-null Map.', + ); + try { + api.onPushDelivered(arg_payload!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel + pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked was null.', + ); + final List args = (message as List?)!; + final Map? arg_payload = + (args[0] as Map?)?.cast(); + assert( + arg_payload != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked was null, expected non-null Map.', + ); + try { + api.onPushClicked(arg_payload!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + } +} diff --git a/lib/src/platform_interface.dart b/lib/src/platform_interface.dart new file mode 100644 index 0000000..7ffd13d --- /dev/null +++ b/lib/src/platform_interface.dart @@ -0,0 +1 @@ +// Pigeon is used for platform communication. diff --git a/lib/src/products/products_list_params.dart b/lib/src/products/products_list_params.dart new file mode 100644 index 0000000..15808a8 --- /dev/null +++ b/lib/src/products/products_list_params.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +/// Parameters for [PersonalizationSdk.getProductsList]. +/// +/// All fields are optional — only non-null/non-empty values are forwarded. +/// Both Android and iOS accept the same parameter set. +/// +/// [filters]: key → list of allowed values, e.g. `{'color': ['red', 'blue']}`. +class ProductsListParams { + final String? brands; + final String? merchants; + final String? categories; + final String? locations; + final int? limit; + final int? page; + final Map>? filters; + + const ProductsListParams({ + this.brands, + this.merchants, + this.categories, + this.locations, + this.limit, + this.page, + this.filters, + }); + + String toJson() => jsonEncode({ + if (brands != null) 'brands': brands, + if (merchants != null) 'merchants': merchants, + if (categories != null) 'categories': categories, + if (locations != null) 'locations': locations, + if (limit != null) 'limit': limit, + if (page != null) 'page': page, + if (filters != null && filters!.isNotEmpty) 'filters': filters, + }); +} diff --git a/lib/src/products/products_list_response.dart b/lib/src/products/products_list_response.dart new file mode 100644 index 0000000..6c848ea --- /dev/null +++ b/lib/src/products/products_list_response.dart @@ -0,0 +1,129 @@ +import '../recommendation/recommendation_response.dart' show ProductCategory; + +export '../recommendation/recommendation_response.dart' show ProductCategory; + +/// Response from [PersonalizationSdk.getProductsList]. +/// +/// Excluded Android-only fields: brands (`List`), categories +/// (`List`), filters (`List`), priceRanges, priceMedian. +/// Excluded iOS-only fields: brands (`[String]`), filters (`[String: Filter]`). +class ProductsListResponse { + final List products; + final int productsTotal; + final PriceRange? priceRange; + + const ProductsListResponse({ + required this.products, + required this.productsTotal, + this.priceRange, + }); + + factory ProductsListResponse.fromJson(Map json) { + return ProductsListResponse( + products: (json['products'] as List? ?? []) + .map((e) => Product.fromJson(e as Map)) + .toList(), + productsTotal: json['products_total'] as int? ?? 0, + priceRange: json['price_range'] == null + ? null + : PriceRange.fromJson(json['price_range'] as Map), + ); + } +} + +/// A product returned by [PersonalizationSdk.getProductsList]. +/// +/// Intersection of Android [com.personalization.api.responses.product.Product] +/// and iOS [ProductInfo]. +/// +/// Note: iOS maps product ID from JSON key "uniqid"; the bridge normalises +/// this to "id" so Dart always reads from "id". +/// +/// Excluded Android-only fields: _id, imageUrlHandle, urlHandle, picture, +/// categoryIds, locationIds. +/// Excluded iOS-only fields: model, barcode, deeplinkIos, resizedImageUrl, +/// oldPrice / oldPriceFull (and formatted variants), discount, isNew, params. +class Product { + final String id; + final String name; + final String brand; + final String description; + final String imageUrl; + final String url; + final double price; + final double priceFull; + final String? priceFormatted; + final String? priceFullFormatted; + final String currency; + final int salesRate; + final double relativeSalesRate; + final Map resizedImages; + final List categories; + + const Product({ + required this.id, + required this.name, + required this.brand, + required this.description, + required this.imageUrl, + required this.url, + required this.price, + required this.priceFull, + this.priceFormatted, + this.priceFullFormatted, + required this.currency, + required this.salesRate, + required this.relativeSalesRate, + required this.resizedImages, + required this.categories, + }); + + factory Product.fromJson(Map json) { + return Product( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + brand: json['brand'] as String? ?? '', + description: json['description'] as String? ?? '', + imageUrl: json['image_url'] as String? ?? '', + url: json['url'] as String? ?? '', + price: (json['price'] as num?)?.toDouble() ?? 0.0, + priceFull: (json['price_full'] as num?)?.toDouble() ?? 0.0, + priceFormatted: json['price_formatted'] as String?, + priceFullFormatted: json['price_full_formatted'] as String?, + currency: json['currency'] as String? ?? '', + salesRate: json['sales_rate'] as int? ?? 0, + relativeSalesRate: + (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + resizedImages: _parseResizedImages(json['image_url_resized']), + categories: (json['categories'] as List? ?? []) + .map((e) => ProductCategory.fromJson(e as Map)) + .toList(), + ); + } +} + +Map _parseResizedImages(dynamic raw) { + if (raw is Map) { + return Map.fromEntries( + raw.entries.map( + (e) => MapEntry(e.key.toString(), e.value?.toString() ?? ''), + ), + ); + } + return {}; +} + +/// Price range for a products list response. +class PriceRange { + final double min; + final double max; + + const PriceRange({required this.min, required this.max}); + + factory PriceRange.fromJson(Map json) { + return PriceRange( + min: (json['min'] as num?)?.toDouble() ?? 0.0, + max: (json['max'] as num?)?.toDouble() ?? 0.0, + ); + } +} diff --git a/lib/src/profile/profile_params.dart b/lib/src/profile/profile_params.dart new file mode 100644 index 0000000..1b1cd48 --- /dev/null +++ b/lib/src/profile/profile_params.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +import '../pigeon/personalization_api.g.dart' as pigeon; + +enum ProfileGender { male, female } + +class ProfileParams { + final String? email; + final String? phone; + final String? loyaltyId; + final String? firstName; + final String? lastName; + + /// Date of birth in "yyyy-MM-dd" format. + final String? birthday; + final int? age; + final ProfileGender? gender; + final String? location; + final String? advertisingId; + final String? fbId; + final String? vkId; + final String? telegramId; + final String? loyaltyCardLocation; + final String? loyaltyStatus; + final int? loyaltyBonuses; + final int? loyaltyBonusesToNextLevel; + final bool? boughtSomething; + final String? userId; + final Map? customProperties; + + const ProfileParams({ + this.email, + this.phone, + this.loyaltyId, + this.firstName, + this.lastName, + this.birthday, + this.age, + this.gender, + this.location, + this.advertisingId, + this.fbId, + this.vkId, + this.telegramId, + this.loyaltyCardLocation, + this.loyaltyStatus, + this.loyaltyBonuses, + this.loyaltyBonusesToNextLevel, + this.boughtSomething, + this.userId, + this.customProperties, + }); + + pigeon.ProfileParamsWire toWire() { + return pigeon.ProfileParamsWire( + email: email, + phone: phone, + loyaltyId: loyaltyId, + firstName: firstName, + lastName: lastName, + birthday: birthday, + age: age, + gender: switch (gender) { + ProfileGender.male => 'm', + ProfileGender.female => 'f', + null => null, + }, + location: location, + advertisingId: advertisingId, + fbId: fbId, + vkId: vkId, + telegramId: telegramId, + loyaltyCardLocation: loyaltyCardLocation, + loyaltyStatus: loyaltyStatus, + loyaltyBonuses: loyaltyBonuses, + loyaltyBonusesToNextLevel: loyaltyBonusesToNextLevel, + boughtSomething: boughtSomething, + userId: userId, + customPropertiesJson: customProperties == null + ? null + : jsonEncode(customProperties), + ); + } +} diff --git a/lib/src/push/push_notification_callbacks.dart b/lib/src/push/push_notification_callbacks.dart new file mode 100644 index 0000000..6e46114 --- /dev/null +++ b/lib/src/push/push_notification_callbacks.dart @@ -0,0 +1,33 @@ +import '../pigeon/personalization_api.g.dart' as pigeon; + +/// Holds optional Dart callbacks for push-related events coming from native code. +class PushNotificationCallbacks implements pigeon.PersonalizationFlutterApi { + void Function(Map payload)? _onReceived; + void Function(Map payload)? _onDelivered; + void Function(Map payload)? _onClicked; + + void setCallbacks({ + void Function(Map payload)? onReceived, + void Function(Map payload)? onDelivered, + void Function(Map payload)? onClicked, + }) { + _onReceived = onReceived; + _onDelivered = onDelivered; + _onClicked = onClicked; + } + + @override + void onPushReceived(Map payload) { + _onReceived?.call(Map.from(payload)); + } + + @override + void onPushDelivered(Map payload) { + _onDelivered?.call(Map.from(payload)); + } + + @override + void onPushClicked(Map payload) { + _onClicked?.call(Map.from(payload)); + } +} diff --git a/lib/src/recommendation/recommendation_params.dart b/lib/src/recommendation/recommendation_params.dart new file mode 100644 index 0000000..59d407d --- /dev/null +++ b/lib/src/recommendation/recommendation_params.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +/// Parameters for [PersonalizationSdk.getRecommendation]. +/// +/// Only fields supported by **both** Android and iOS native SDKs are included. +/// Android-only params not exposed here: `limit`, `brands`, `excludeBrands`, +/// `categories`, `discount`, `fullCart`, `fullWish`, `orderId`, `orderPrice`, +/// `searchQuery`. iOS does not expose these through its `recommend()` method. +class RecommendationParams { + /// ID of the current product shown to the user. + final String? itemId; + + /// ID of the current category shown to the user. + final String? categoryId; + + /// Comma-separated location IDs to filter recommendations. + final String? locations; + + /// Image resize size. Available values: 120, 140, 160, 180, 200, 220, 310, 520. + final int? imageSize; + + /// Whether to include location objects in product data. Defaults to false. + final bool withLocations; + + const RecommendationParams({ + this.itemId, + this.categoryId, + this.locations, + this.imageSize, + this.withLocations = false, + }); + + String toJson() => jsonEncode({ + if (itemId != null) 'item_id': itemId, + if (categoryId != null) 'category_id': categoryId, + if (locations != null) 'locations': locations, + if (imageSize != null) 'image_size': imageSize, + 'with_locations': withLocations, + }); +} diff --git a/lib/src/recommendation/recommendation_response.dart b/lib/src/recommendation/recommendation_response.dart new file mode 100644 index 0000000..95bce98 --- /dev/null +++ b/lib/src/recommendation/recommendation_response.dart @@ -0,0 +1,147 @@ +/// Typed response for [PersonalizationSdk.getRecommendation]. +/// +/// Contains only fields present in **both** Android and iOS native SDK models. +/// +/// Fields available in the native Android SDK but excluded from this contract: +/// - `html` — rendered block HTML (`GetExtendedRecommendationResponse.html`). +/// - `id` — block ID (`GetExtendedRecommendationResponse.id`). +/// - `Product.imageUrlHandle`, `Product.urlHandle` — URL handle variants. +/// - `Product.categoryIds` — flat list of category IDs (use `categories` instead). +/// - `Product.locationIds` — flat list of location IDs (use `locations` on iOS). +/// +/// Fields available in the native iOS SDK but excluded from this contract: +/// - `Product.barcode`, `Product.model`, `Product.deeplinkIos`. +/// - `Product.oldPrice`, `Product.oldPriceFull`, `Product.oldPriceFormatted`, +/// `Product.oldPriceFullFormatted` — original price before discount. +/// - `Product.discount`, `Product.rating`. +/// - `Product.fashionSizes`, `Product.fashionColors`, `Product.fashionOriginalSizes`. +/// - `Product.locations` — full location objects (Android only exposes IDs). +/// - `Product.paramsRaw` — raw extra params from the API response. +/// - `RecommenderResponse.locations` — top-level locations list. +/// +/// To unlock platform-specific fields, extend the corresponding native SDK +/// model and re-generate the Flutter bridge. +class RecommendationResponse { + final String title; + final List products; + + const RecommendationResponse({required this.title, required this.products}); + + factory RecommendationResponse.fromJson(Map json) { + final rawProducts = json['recommends'] as List? ?? []; + return RecommendationResponse( + title: json['title'] as String? ?? '', + products: rawProducts + .map((e) => RecommendedProduct.fromJson(e as Map)) + .toList(), + ); + } +} + +/// A single recommended product. +/// +/// See [RecommendationResponse] for the full list of excluded platform-specific fields. +class RecommendedProduct { + final String id; + final String name; + final String brand; + final String description; + + /// Original image URL (`image_url` in API response). + final String imageUrl; + + /// Resized/thumbnail image URL (`picture` in API response). + final String resizedImageUrl; + + /// Map of pre-resized image URLs keyed by pixel size string + /// e.g. `{"120": "...", "310": "..."}` (`image_url_resized` in API response). + final Map resizedImages; + + final String url; + final List categories; + + final double price; + final double priceFull; + final String? priceFormatted; + final String? priceFullFormatted; + final String currency; + final int salesRate; + final double relativeSalesRate; + + const RecommendedProduct({ + required this.id, + required this.name, + required this.brand, + required this.description, + required this.imageUrl, + required this.resizedImageUrl, + required this.resizedImages, + required this.url, + required this.categories, + required this.price, + required this.priceFull, + this.priceFormatted, + this.priceFullFormatted, + required this.currency, + required this.salesRate, + required this.relativeSalesRate, + }); + + factory RecommendedProduct.fromJson(Map json) { + final rawCategories = json['categories'] as List? ?? []; + final rawResized = json['image_url_resized']; + return RecommendedProduct( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + brand: json['brand'] as String? ?? '', + description: json['description'] as String? ?? '', + imageUrl: json['image_url'] as String? ?? '', + resizedImageUrl: json['picture'] as String? ?? '', + resizedImages: rawResized is Map + ? rawResized.cast() + : const {}, + url: json['url'] as String? ?? '', + categories: rawCategories + .map((e) => ProductCategory.fromJson(e as Map)) + .toList(), + price: (json['price'] as num?)?.toDouble() ?? 0.0, + priceFull: (json['price_full'] as num?)?.toDouble() ?? 0.0, + priceFormatted: json['price_formatted'] as String?, + priceFullFormatted: json['price_full_formatted'] as String?, + currency: json['currency'] as String? ?? '', + salesRate: json['sales_rate'] as int? ?? 0, + relativeSalesRate: + (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + ); + } +} + +/// Category attached to a [RecommendedProduct]. +/// +/// Fields available in the native Android SDK but excluded: +/// - `level`, `nameWithParent`, `urlHandle`. +/// +/// Fields available in the native iOS SDK but excluded: +/// - `alias`, `count`. +class ProductCategory { + final String id; + final String name; + final String? parentId; + final String? url; + + const ProductCategory({ + required this.id, + required this.name, + this.parentId, + this.url, + }); + + factory ProductCategory.fromJson(Map json) { + return ProductCategory( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + parentId: json['parent_id'] as String?, + url: json['url'] as String?, + ); + } +} diff --git a/lib/src/sdk_init_config.dart b/lib/src/sdk_init_config.dart new file mode 100644 index 0000000..12616b7 --- /dev/null +++ b/lib/src/sdk_init_config.dart @@ -0,0 +1,28 @@ +class SdkInitConfig { + final String shopId; + final String? apiDomain; + final String? stream; + + /// Enables verbose SDK logging. **iOS only** — ignored on Android (the + /// published Android SDK artifact does not expose a logging toggle). + final bool? enableLogs; + final bool? autoSendPushToken; + + /// Opt-in to IDFA/GAID collection. **iOS only** — ignored on Android (the + /// published Android SDK artifact always collects the advertising ID + /// internally via `InitializeAdvertisingIdUseCase`). + final bool? sendAdvertisingId; + final bool? enableAutoPopupPresentation; + final bool? needReInitialization; + + const SdkInitConfig({ + required this.shopId, + this.apiDomain, + this.stream, + this.enableLogs, + this.autoSendPushToken, + this.sendAdvertisingId, + this.enableAutoPopupPresentation, + this.needReInitialization, + }); +} diff --git a/lib/src/search/search_params.dart b/lib/src/search/search_params.dart new file mode 100644 index 0000000..3df4467 --- /dev/null +++ b/lib/src/search/search_params.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +/// Parameters for [PersonalizationSdk.searchFull]. +/// +/// All fields are optional — only non-null/non-empty values are forwarded to the native SDK. +/// +/// Cross-platform notes: +/// - [categories]: list of category ID strings. Android joins them with commas; +/// iOS converts them to integers internally. +/// - [page]: maps to Android `PAGE` param and iOS `offset` param. +/// +/// Excluded Android-only params: `extended`, `exclude`, `excludedMerchants`, +/// `noClarification`, `filters` (complex SearchFilters object). +class SearchParams { + final int? limit; + final int? page; + final int? categoryLimit; + final int? brandLimit; + final List? categories; + final String? sortBy; + final String? sortDir; + final String? locations; + final List? excludedBrands; + final String? brands; + final double? priceMin; + final double? priceMax; + final List? colors; + final List? fashionSizes; + + const SearchParams({ + this.limit, + this.page, + this.categoryLimit, + this.brandLimit, + this.categories, + this.sortBy, + this.sortDir, + this.locations, + this.excludedBrands, + this.brands, + this.priceMin, + this.priceMax, + this.colors, + this.fashionSizes, + }); + + String toJson() => jsonEncode({ + if (limit != null) 'limit': limit, + if (page != null) 'page': page, + if (categoryLimit != null) 'category_limit': categoryLimit, + if (brandLimit != null) 'brand_limit': brandLimit, + if (categories != null && categories!.isNotEmpty) 'categories': categories, + if (sortBy != null) 'sort_by': sortBy, + if (sortDir != null) 'sort_dir': sortDir, + if (locations != null) 'locations': locations, + if (excludedBrands != null && excludedBrands!.isNotEmpty) + 'excluded_brands': excludedBrands, + if (brands != null) 'brands': brands, + if (priceMin != null) 'price_min': priceMin, + if (priceMax != null) 'price_max': priceMax, + if (colors != null && colors!.isNotEmpty) 'colors': colors, + if (fashionSizes != null && fashionSizes!.isNotEmpty) + 'fashion_sizes': fashionSizes, + }); +} + +/// Parameters for [PersonalizationSdk.searchInstant]. +/// +/// Android passes these directly to `searchManager.searchInstant()`. +/// iOS passes them to `sdk.search()` with the remaining fields set to nil. +/// +/// Excluded Android-only params: `excludedMerchants`. +class SearchInstantParams { + final String? locations; + final List? excludedBrands; + + const SearchInstantParams({this.locations, this.excludedBrands}); + + String toJson() => jsonEncode({ + if (locations != null) 'locations': locations, + if (excludedBrands != null && excludedBrands!.isNotEmpty) + 'excluded_brands': excludedBrands, + }); +} diff --git a/lib/src/search/search_response.dart b/lib/src/search/search_response.dart new file mode 100644 index 0000000..c8329d3 --- /dev/null +++ b/lib/src/search/search_response.dart @@ -0,0 +1,258 @@ +/// Response from [PersonalizationSdk.searchFull]. +/// +/// Excluded Android-only response fields: brands (`List`), clarification, +/// collections, html, priceMedian, priceRanges, requestsCount, searchQuery. +/// Excluded iOS-only response fields: filters, industrialFilters, redirect, queries. +class SearchFullResponse { + final List products; + final List categories; + final int productsTotal; + final SearchPriceRange? priceRange; + final List? locations; + + const SearchFullResponse({ + required this.products, + required this.categories, + required this.productsTotal, + this.priceRange, + this.locations, + }); + + factory SearchFullResponse.fromJson(Map json) { + return SearchFullResponse( + products: (json['products'] as List? ?? []) + .map((e) => SearchProduct.fromJson(e as Map)) + .toList(), + categories: (json['categories'] as List? ?? []) + .map((e) => SearchCategory.fromJson(e as Map)) + .toList(), + productsTotal: json['products_total'] as int? ?? 0, + priceRange: json['price_range'] == null + ? null + : SearchPriceRange.fromJson( + json['price_range'] as Map, + ), + locations: (json['locations'] as List?) + ?.map((e) => SearchLocation.fromJson(e as Map)) + .toList(), + ); + } +} + +/// Search product — intersection of Android [com.personalization.api.responses.product.Product] +/// and iOS [Product] (from SearchResponse). +/// +/// Excluded Android-only fields: _id, imageUrlHandle, urlHandle, picture, categoryIds, +/// locationIds, categories (product-level category model differs from search-level). +/// Excluded iOS-only fields: barcode, model, deeplinkIos, oldPrice / oldPriceFull +/// (and their formatted variants), discount, isNew, params. +class SearchProduct { + final String id; + final String name; + final String brand; + final String description; + final String imageUrl; + final String url; + final double price; + final double priceFull; + final String? priceFormatted; + final String? priceFullFormatted; + final String currency; + final int salesRate; + final double relativeSalesRate; + + /// Resized image URLs keyed by size string (e.g. "120", "520"). + /// JSON key: "image_url_resized" on both platforms. + final Map resizedImages; + + const SearchProduct({ + required this.id, + required this.name, + required this.brand, + required this.description, + required this.imageUrl, + required this.url, + required this.price, + required this.priceFull, + this.priceFormatted, + this.priceFullFormatted, + required this.currency, + required this.salesRate, + required this.relativeSalesRate, + required this.resizedImages, + }); + + factory SearchProduct.fromJson(Map json) { + return SearchProduct( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + brand: json['brand'] as String? ?? '', + description: json['description'] as String? ?? '', + imageUrl: json['image_url'] as String? ?? '', + url: json['url'] as String? ?? '', + price: (json['price'] as num?)?.toDouble() ?? 0.0, + priceFull: (json['price_full'] as num?)?.toDouble() ?? 0.0, + priceFormatted: json['price_formatted'] as String?, + priceFullFormatted: json['price_full_formatted'] as String?, + currency: json['currency'] as String? ?? '', + salesRate: json['sales_rate'] as int? ?? 0, + relativeSalesRate: + (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + resizedImages: _parseResizedImages(json['image_url_resized']), + ); + } +} + +/// Response from [PersonalizationSdk.searchInstant]. +/// +/// Subset of [SearchFullResponse] — no [priceRange] (not present in Android +/// `SearchInstantResponse`). Shares the same [SearchProduct], [SearchCategory], +/// and [SearchLocation] models. +/// +/// Excluded Android-only fields: clarification, collections, html, requestsCount, +/// searchQuery, bookAuthor, queries. +/// Excluded iOS-only fields: filters, industrialFilters, redirect, queries, brands. +class SearchInstantResponse { + final List products; + final List categories; + final int productsTotal; + final List? locations; + + const SearchInstantResponse({ + required this.products, + required this.categories, + required this.productsTotal, + this.locations, + }); + + factory SearchInstantResponse.fromJson(Map json) { + return SearchInstantResponse( + products: (json['products'] as List? ?? []) + .map((e) => SearchProduct.fromJson(e as Map)) + .toList(), + categories: (json['categories'] as List? ?? []) + .map((e) => SearchCategory.fromJson(e as Map)) + .toList(), + productsTotal: json['products_total'] as int? ?? 0, + locations: (json['locations'] as List?) + ?.map((e) => SearchLocation.fromJson(e as Map)) + .toList(), + ); + } +} + +Map _parseResizedImages(dynamic raw) { + if (raw is Map) { + return Map.fromEntries( + raw.entries.map( + (e) => MapEntry(e.key.toString(), e.value?.toString() ?? ''), + ), + ); + } + return {}; +} + +/// Top-level search category — intersection of Android and iOS search category models. +/// +/// Note: [parentId] is decoded from JSON key "parent" (same on both platforms). +/// Android field name: `parent`; iOS field name: `parentId` — both use JSON key "parent". +/// +/// Excluded Android-only fields: urlHandle. +/// Excluded iOS-only fields: alias. +class SearchCategory { + final String id; + final String name; + final String? url; + final String? parentId; + final int? count; + + const SearchCategory({ + required this.id, + required this.name, + this.url, + this.parentId, + this.count, + }); + + factory SearchCategory.fromJson(Map json) { + return SearchCategory( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + url: json['url'] as String?, + parentId: json['parent'] as String?, + count: json['count'] as int?, + ); + } +} + +class SearchPriceRange { + final double min; + final double max; + + const SearchPriceRange({required this.min, required this.max}); + + factory SearchPriceRange.fromJson(Map json) { + return SearchPriceRange( + min: (json['min'] as num?)?.toDouble() ?? 0.0, + max: (json['max'] as num?)?.toDouble() ?? 0.0, + ); + } +} + +/// Response from [PersonalizationSdk.searchBlank]. +/// +/// Contains trending/popular products and search suggestions configured for the shop. +/// +/// Excluded Android-only fields: html. +/// Excluded iOS-only fields: lastQueries, lastProducts. +class SearchBlankResponse { + final List products; + final List suggests; + + const SearchBlankResponse({required this.products, required this.suggests}); + + factory SearchBlankResponse.fromJson(Map json) { + return SearchBlankResponse( + products: (json['products'] as List? ?? []) + .map((e) => SearchProduct.fromJson(e as Map)) + .toList(), + suggests: (json['suggests'] as List? ?? []) + .map((e) => SearchSuggest.fromJson(e as Map)) + .toList(), + ); + } +} + +/// A search suggestion entry returned by [PersonalizationSdk.searchBlank]. +/// +/// Excluded iOS-only fields: deeplinkIos. +class SearchSuggest { + final String name; + final String url; + + const SearchSuggest({required this.name, required this.url}); + + factory SearchSuggest.fromJson(Map json) { + return SearchSuggest( + name: json['name'] as String? ?? '', + url: json['url'] as String? ?? '', + ); + } +} + +/// Search location — present in both Android and iOS search responses. +class SearchLocation { + final String id; + final String name; + final String? type; + + const SearchLocation({required this.id, required this.name, this.type}); + + factory SearchLocation.fromJson(Map json) { + return SearchLocation( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + type: json['type'] as String?, + ); + } +} diff --git a/lib/src/tracking/purchase_line_item.dart b/lib/src/tracking/purchase_line_item.dart new file mode 100644 index 0000000..3616028 --- /dev/null +++ b/lib/src/tracking/purchase_line_item.dart @@ -0,0 +1,25 @@ +/// One purchased line item (strict mobile contract, aligned with native +/// `PurchaseItemRequest` on Android / iOS). +/// +/// [amount] is the number of units of this product in the order — it is the +/// canonical quantity field the REES46 API has always consumed. The native +/// `PurchaseItemRequest` additionally carries an optional `quantity` alias that +/// duplicates [amount]; it is intentionally not surfaced here to keep a single, +/// unambiguous quantity field. +class PurchaseLineItem { + final String id; + + /// Number of units of this product in the order (the line quantity). + final int amount; + final double price; + final String? lineId; + final String? fashionSize; + + const PurchaseLineItem({ + required this.id, + required this.amount, + required this.price, + this.lineId, + this.fashionSize, + }); +} diff --git a/pigeons/personalization_api.dart b/pigeons/personalization_api.dart new file mode 100644 index 0000000..4f55820 --- /dev/null +++ b/pigeons/personalization_api.dart @@ -0,0 +1,204 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartPackageName: 'personalization_flutter_sdk', + dartOut: 'lib/src/pigeon/personalization_api.g.dart', + kotlinOut: + 'android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt', + kotlinOptions: KotlinOptions( + package: 'com.rees46.rees46_flutter_sdk.pigeon', + ), + swiftOut: 'ios/Classes/pigeon/PersonalizationApi.g.swift', + ), +) +class InitConfig { + final String shopId; + final String apiDomain; + final String stream; + final bool enableLogs; + final bool autoSendPushToken; + final bool sendAdvertisingId; + final bool enableAutoPopupPresentation; + final bool needReInitialization; + + const InitConfig({ + required this.shopId, + required this.apiDomain, + required this.stream, + required this.enableLogs, + required this.autoSendPushToken, + required this.sendAdvertisingId, + required this.enableAutoPopupPresentation, + required this.needReInitialization, + }); +} + +/// 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 { + final String id; + + /// Number of units of this product in the order (the line quantity). + final int amount; + final double price; + final String? lineId; + final String? fashionSize; + + const PurchaseLineItemWire({ + required this.id, + required this.amount, + required this.price, + this.lineId, + this.fashionSize, + }); +} + +/// Wire format for profile fields sent to native SDK. +/// All fields are optional — only non-null values are forwarded. +/// [birthday] must be a "yyyy-MM-dd" string. +/// [gender] must be "m" or "f". +/// [customPropertiesJson] is a JSON object string or null. +class ProfileParamsWire { + final String? email; + final String? phone; + final String? loyaltyId; + final String? firstName; + final String? lastName; + final String? birthday; + final int? age; + final String? gender; + final String? location; + final String? advertisingId; + final String? fbId; + final String? vkId; + final String? telegramId; + final String? loyaltyCardLocation; + final String? loyaltyStatus; + final int? loyaltyBonuses; + final int? loyaltyBonusesToNextLevel; + final bool? boughtSomething; + final String? userId; + final String? customPropertiesJson; + + const ProfileParamsWire({ + this.email, + this.phone, + this.loyaltyId, + this.firstName, + this.lastName, + this.birthday, + this.age, + this.gender, + this.location, + this.advertisingId, + this.fbId, + this.vkId, + this.telegramId, + this.loyaltyCardLocation, + this.loyaltyStatus, + this.loyaltyBonuses, + this.loyaltyBonusesToNextLevel, + this.boughtSomething, + this.userId, + this.customPropertiesJson, + }); +} + +@HostApi() +abstract class PersonalizationHostApi { + @async + void initialize(InitConfig config); + + String getPlatformVersion(); + + /// Returns the push token stored by the native SDK (if any). + String? getStoredPushToken(); + + /// [customFieldsJson] is JSON object string or null (maps to native custom fields map). + @async + void trackEvent( + String event, + int? time, + String? category, + String? label, + int? value, + String? customFieldsJson, + ); + + @async + void setProfile(ProfileParamsWire params); + + /// Returns the recommendation block as a JSON string. + /// [paramsJson] is a JSON object string with optional filter parameters. + /// Dart layer parses the result into [RecommendationResponse]. + @async + String getRecommendation(String code, String? paramsJson); + + /// Returns the current session ID from the native SDK. + String getSid(); + + /// Returns the device ID assigned by the native SDK, or null before first sync. + String? getDid(); + + /// Returns a single product's details as a JSON string. + /// Dart layer parses the result into [Product]. + @async + String getProductInfo(String itemId); + + /// Returns a paginated product catalog list as a JSON string. + /// [paramsJson] is a JSON object with optional filter fields. + /// Dart layer parses the result into [ProductsListResponse]. + @async + String getProductsList(String? paramsJson); + + /// Returns blank search results (trending/popular) as a JSON string. + /// No parameters — the native SDK decides what to return based on shop config. + /// Dart layer parses the result into [SearchBlankResponse]. + @async + String searchBlank(); + + /// Returns instant (typeahead) search results as a JSON string. + /// [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). + /// Dart layer parses the result into [SearchInstantResponse]. + @async + String searchInstant(String query, String? paramsJson); + + /// Returns full search results as a JSON string. + /// [paramsJson] is a JSON object string with optional search parameters. + /// Dart layer parses the result into [SearchFullResponse]. + @async + String searchFull(String query, String? paramsJson); + + /// [customJson] and [recommendedSourceJson] are JSON object strings or null. + @async + void trackPurchase( + String orderId, + double orderPrice, + List items, + String? deliveryType, + String? deliveryAddress, + String? paymentType, + bool isTaxFree, + String? promocode, + double? orderCash, + double? orderBonuses, + double? orderDelivery, + double? orderDiscount, + String? channel, + String? customJson, + String? recommendedSourceJson, + String? stream, + String? segment, + ); +} + +@FlutterApi() +abstract class PersonalizationFlutterApi { + void onPushReceived(Map payload); + + void onPushDelivered(Map payload); + + void onPushClicked(Map payload); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..56e00d9 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,73 @@ +name: personalization_flutter_sdk +description: "Flutter plugin wrapper around REES46/PersonaClick native SDKs." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.11.1 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + pigeon: ^25.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.rees46.rees46_flutter_sdk + pluginClass: Rees46FlutterSdkPlugin + ios: + pluginClass: Rees46FlutterSdkPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/test/personalization_sdk_test.dart b/test/personalization_sdk_test.dart new file mode 100644 index 0000000..49de51f --- /dev/null +++ b/test/personalization_sdk_test.dart @@ -0,0 +1,276 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +// Channel names from generated Pigeon code. +const _trackEventChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackEvent'; +const _trackPurchaseChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase'; +// Stub channels registered by PersonalizationFlutterApi.setUp in the SDK ctor. +const _onReceivedChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived'; +const _onDeliveredChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered'; +const _onClickedChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const codec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + void mockChannel(String channel, MessageHandler handler) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, handler); + + void unmockChannel(String channel) => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMessageHandler(channel, null); + + MessageHandler successHandler() => + (ByteData? _) async => codec.encodeMessage([]); + + setUp(() { + // Stub FlutterApi push channels so PersonalizationFlutterApi.setUp in + // the SDK constructor does not print "channel not mocked" warnings. + mockChannel(_onReceivedChannel, successHandler()); + mockChannel(_onDeliveredChannel, successHandler()); + mockChannel(_onClickedChannel, successHandler()); + mockChannel(_trackEventChannel, successHandler()); + mockChannel(_trackPurchaseChannel, successHandler()); + }); + + tearDown(() { + for (final ch in [ + _onReceivedChannel, + _onDeliveredChannel, + _onClickedChannel, + _trackEventChannel, + _trackPurchaseChannel, + ]) { + unmockChannel(ch); + } + }); + + // Installs a capturing handler on [channel] and returns a getter for the + // decoded argument list. + List Function() captureArgs(String channel) { + List? captured; + mockChannel(channel, (ByteData? msg) async { + captured = codec.decodeMessage(msg) as List; + return codec.encodeMessage([]); + }); + return () => captured!; + } + + // --------------------------------------------------------------------------- + // trackEvent + // --------------------------------------------------------------------------- + group('trackEvent validation', () { + test( + 'empty event throws ArgumentError synchronously before channel call', + () { + final sdk = PersonalizationSdk(); + expect(() => sdk.trackEvent(''), throwsArgumentError); + }, + ); + + test('non-empty event does not throw synchronously', () { + final sdk = PersonalizationSdk(); + expect(() => sdk.trackEvent('click'), returnsNormally); + }); + }); + + group('trackEvent channel args', () { + test('customFields null → customFieldsJson (arg[5]) is null', () async { + final getArgs = captureArgs(_trackEventChannel); + await PersonalizationSdk().trackEvent('ev'); + expect(getArgs()[5], isNull); + }); + + test('customFields provided → valid JSON at arg[5]', () async { + final getArgs = captureArgs(_trackEventChannel); + await PersonalizationSdk().trackEvent( + 'ev', + customFields: {'source': 'app', 'count': 3}, + ); + final json = jsonDecode(getArgs()[5] as String) as Map; + expect(json['source'], 'app'); + expect(json['count'], 3); + }); + + test('all positional args sent in correct order', () async { + final getArgs = captureArgs(_trackEventChannel); + await PersonalizationSdk().trackEvent( + 'my_event', + time: 1700000000, + category: 'cat', + label: 'lbl', + value: 42, + customFields: {'k': 'v'}, + ); + final args = getArgs(); + expect(args[0], 'my_event'); // event + expect(args[1], 1700000000); // time + expect(args[2], 'cat'); // category + expect(args[3], 'lbl'); // label + expect(args[4], 42); // value + expect(args[5], isNotNull); // customFieldsJson + }); + + test('customFields with null value → key preserved in JSON', () async { + final getArgs = captureArgs(_trackEventChannel); + await PersonalizationSdk().trackEvent( + 'ev', + customFields: {'present': 'yes', 'absent': null}, + ); + final json = jsonDecode(getArgs()[5] as String) as Map; + expect(json.containsKey('absent'), true); + expect(json['absent'], isNull); + }); + }); + + // --------------------------------------------------------------------------- + // trackPurchase + // --------------------------------------------------------------------------- + group('trackPurchase validation', () { + test('empty orderId throws ArgumentError synchronously', () { + final sdk = PersonalizationSdk(); + expect( + () => sdk.trackPurchase( + orderId: '', + orderPrice: 10.0, + items: [const PurchaseLineItem(id: 'sku', amount: 1, price: 10.0)], + ), + throwsArgumentError, + ); + }); + + test('empty items throws ArgumentError synchronously', () { + final sdk = PersonalizationSdk(); + expect( + () => sdk.trackPurchase(orderId: 'ord-1', orderPrice: 10.0, items: []), + throwsArgumentError, + ); + }); + }); + + group('trackPurchase channel args', () { + test('PurchaseLineItem maps correctly to wire type', () async { + final getArgs = captureArgs(_trackPurchaseChannel); + await PersonalizationSdk().trackPurchase( + orderId: 'ord-1', + orderPrice: 99.0, + items: [ + const PurchaseLineItem( + id: 'sku-1', + amount: 2, + price: 49.5, + lineId: 'line-A', + fashionSize: 'M', + ), + ], + ); + final wire = + (getArgs()[2] as List)[0] as pigeon.PurchaseLineItemWire; + expect(wire.id, 'sku-1'); + expect(wire.amount, 2); + expect(wire.price, 49.5); + expect(wire.lineId, 'line-A'); + expect(wire.fashionSize, 'M'); + }); + + test('optional item fields are null when not set', () async { + final getArgs = captureArgs(_trackPurchaseChannel); + await PersonalizationSdk().trackPurchase( + orderId: 'ord-1', + orderPrice: 10.0, + items: [const PurchaseLineItem(id: 'sku', amount: 1, price: 10.0)], + ); + final wire = + (getArgs()[2] as List)[0] as pigeon.PurchaseLineItemWire; + expect(wire.lineId, isNull); + expect(wire.fashionSize, isNull); + }); + + test('custom null → customJson (arg[13]) is null', () async { + final getArgs = captureArgs(_trackPurchaseChannel); + await PersonalizationSdk().trackPurchase( + orderId: 'ord', + orderPrice: 1.0, + items: [const PurchaseLineItem(id: 'x', amount: 1, price: 1.0)], + ); + expect(getArgs()[13], isNull); + }); + + test('custom provided → valid JSON at arg[13]', () async { + final getArgs = captureArgs(_trackPurchaseChannel); + await PersonalizationSdk().trackPurchase( + orderId: 'ord', + orderPrice: 1.0, + items: [const PurchaseLineItem(id: 'x', amount: 1, price: 1.0)], + custom: {'channel': 'app'}, + ); + final json = jsonDecode(getArgs()[13] as String) as Map; + expect(json['channel'], 'app'); + }); + + test('recommendedSource provided → valid JSON at arg[14]', () async { + final getArgs = captureArgs(_trackPurchaseChannel); + await PersonalizationSdk().trackPurchase( + orderId: 'ord', + orderPrice: 1.0, + items: [const PurchaseLineItem(id: 'x', amount: 1, price: 1.0)], + recommendedSource: {'type': 'popular', 'code': 'rec-1'}, + ); + final json = jsonDecode(getArgs()[14] as String) as Map; + expect(json['type'], 'popular'); + expect(json['code'], 'rec-1'); + }); + + test('all positional args sent in correct order', () async { + final getArgs = captureArgs(_trackPurchaseChannel); + await PersonalizationSdk().trackPurchase( + orderId: 'order-42', + orderPrice: 150.0, + items: [const PurchaseLineItem(id: 'sku', amount: 1, price: 150.0)], + deliveryType: 'courier', + deliveryAddress: 'Main St', + paymentType: 'card', + isTaxFree: true, + promocode: 'SAVE10', + orderCash: 0.0, + orderBonuses: 5.0, + orderDelivery: 0.0, + orderDiscount: 10.0, + channel: 'mobile', + stream: 'android', + segment: 'vip', + ); + final args = getArgs(); + expect(args[0], 'order-42'); // orderId + expect(args[1], 150.0); // orderPrice + // args[2] = items (List) + expect(args[3], 'courier'); // deliveryType + expect(args[4], 'Main St'); // deliveryAddress + expect(args[5], 'card'); // paymentType + expect(args[6], true); // isTaxFree + expect(args[7], 'SAVE10'); // promocode + expect(args[8], 0.0); // orderCash + expect(args[9], 5.0); // orderBonuses + expect(args[10], 0.0); // orderDelivery + expect(args[11], 10.0); // orderDiscount + expect(args[12], 'mobile'); // channel + // args[13] = customJson + // args[14] = recommendedSourceJson + expect(args[15], 'android'); // stream + expect(args[16], 'vip'); // segment + }); + }); +} diff --git a/test/product_info_test.dart b/test/product_info_test.dart new file mode 100644 index 0000000..c0c8c2e --- /dev/null +++ b/test/product_info_test.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +const _stubJson = { + 'id': 'item-42', + 'name': 'Pro Widget', + 'brand': 'Widgetco', + 'description': 'The best widget', + 'image_url': 'https://img.test/42.jpg', + 'image_url_resized': { + '120': 'https://img.test/42-120.jpg', + '520': 'https://img.test/42-520.jpg', + }, + 'url': 'https://shop.test/42', + 'price': 149.99, + 'price_full': 199.99, + 'price_formatted': r'$149.99', + 'price_full_formatted': r'$199.99', + 'currency': 'USD', + 'sales_rate': 73, + 'relative_sales_rate': 0.8, + 'categories': [ + {'id': 'c1', 'name': 'Widgets', 'parent_id': null, 'url': '/widgets'}, + ], +}; + +const _channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductInfo'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final messageCodec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + ByteData reply(String value) => messageCodec.encodeMessage([value])!; + + void stubOk([Map? json]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply(jsonEncode(json ?? _stubJson)), + ); + } + + // ------------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------------- + group('getProductInfo — validation', () { + test('empty itemId throws ArgumentError', () { + final sdk = PersonalizationSdk(); + expect(() => sdk.getProductInfo(''), throwsArgumentError); + }); + + test('non-empty itemId does not throw synchronously', () { + stubOk(); + expect( + () => PersonalizationSdk().getProductInfo('item-1'), + returnsNormally, + ); + }); + }); + + // ------------------------------------------------------------------------- + // Channel arguments + // ------------------------------------------------------------------------- + group('getProductInfo — channel args', () { + test('itemId forwarded correctly', () async { + List? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return reply(jsonEncode(_stubJson)); + }); + + await PersonalizationSdk().getProductInfo('item-99'); + expect(captured![0], equals('item-99')); + }); + }); + + // ------------------------------------------------------------------------- + // Response parsing + // ------------------------------------------------------------------------- + group('getProductInfo — response parsing', () { + late PersonalizationSdk sdk; + + setUp(() { + sdk = PersonalizationSdk(); + stubOk(); + }); + + test('returns Product type', () async { + final p = await sdk.getProductInfo('item-42'); + expect(p, isA()); + }); + + test('all fields parsed correctly', () async { + final p = await sdk.getProductInfo('item-42'); + expect(p.id, equals('item-42')); + expect(p.name, equals('Pro Widget')); + expect(p.brand, equals('Widgetco')); + expect(p.description, equals('The best widget')); + expect(p.imageUrl, equals('https://img.test/42.jpg')); + expect(p.url, equals('https://shop.test/42')); + expect(p.price, equals(149.99)); + expect(p.priceFull, equals(199.99)); + expect(p.priceFormatted, equals(r'$149.99')); + expect(p.currency, equals('USD')); + expect(p.salesRate, equals(73)); + expect(p.relativeSalesRate, equals(0.8)); + }); + + test('resizedImages parsed', () async { + final p = await sdk.getProductInfo('item-42'); + expect(p.resizedImages['120'], equals('https://img.test/42-120.jpg')); + expect(p.resizedImages['520'], equals('https://img.test/42-520.jpg')); + }); + + test('categories parsed', () async { + final p = await sdk.getProductInfo('item-42'); + expect(p.categories.length, equals(1)); + expect(p.categories.first.id, equals('c1')); + expect(p.categories.first.name, equals('Widgets')); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + group('getProductInfo — edge cases', () { + test('minimal JSON with only required fields does not throw', () async { + stubOk({ + 'id': 'x', + 'name': '', + 'brand': '', + 'description': '', + 'image_url': '', + 'url': '', + 'price': 0, + 'price_full': 0, + 'currency': '', + 'sales_rate': 0, + 'relative_sales_rate': 0, + }); + final p = await PersonalizationSdk().getProductInfo('x'); + expect(p.id, equals('x')); + expect(p.priceFormatted, isNull); + expect(p.resizedImages, isEmpty); + expect(p.categories, isEmpty); + }); + + test( + 'iOS-style uniqid in JSON is ignored — id key takes precedence', + () async { + stubOk({ + 'id': 'normalized-id', + 'uniqid': 'raw-ios-id', + 'name': '', + 'brand': '', + 'description': '', + 'image_url': '', + 'url': '', + 'price': 0, + 'price_full': 0, + 'currency': '', + 'sales_rate': 0, + 'relative_sales_rate': 0, + }); + final p = await PersonalizationSdk().getProductInfo('normalized-id'); + expect(p.id, equals('normalized-id')); + }, + ); + }); +} diff --git a/test/products_list_test.dart b/test/products_list_test.dart new file mode 100644 index 0000000..62c719d --- /dev/null +++ b/test/products_list_test.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +const _stubJson = { + 'products': [ + { + 'id': 'prod-1', + 'name': 'Test Product', + 'brand': 'Test Brand', + 'description': 'A test product', + 'image_url': 'https://img.test/1.jpg', + 'image_url_resized': {'120': 'https://img.test/120.jpg'}, + 'url': 'https://shop.test/1', + 'price': 299.0, + 'price_full': 399.0, + 'price_formatted': r'$299', + 'price_full_formatted': r'$399', + 'currency': 'USD', + 'sales_rate': 55, + 'relative_sales_rate': 0.6, + 'categories': [ + { + 'id': 'c1', + 'name': 'Electronics', + 'parent_id': null, + 'url': '/electronics', + }, + ], + }, + ], + 'products_total': 42, + 'price_range': {'min': 100.0, 'max': 9999.0}, +}; + +const _channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductsList'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final messageCodec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + ByteData reply(String value) => messageCodec.encodeMessage([value])!; + + void stubOk([Map? json]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply(jsonEncode(json ?? _stubJson)), + ); + } + + // ------------------------------------------------------------------------- + // Channel arguments + // ------------------------------------------------------------------------- + group('getProductsList — channel args', () { + test('null paramsJson when no ProductsListParams', () async { + List? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return reply(jsonEncode(_stubJson)); + }); + + await PersonalizationSdk().getProductsList(); + expect(captured![0], isNull); + }); + + test('params serialised into paramsJson', () async { + List? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return reply(jsonEncode(_stubJson)); + }); + + await PersonalizationSdk().getProductsList( + params: const ProductsListParams( + brands: 'Apple,Samsung', + limit: 20, + page: 2, + categories: '1,2,3', + ), + ); + + final p = jsonDecode(captured![0] as String) as Map; + expect(p['brands'], equals('Apple,Samsung')); + expect(p['limit'], equals(20)); + expect(p['page'], equals(2)); + expect(p['categories'], equals('1,2,3')); + }); + + test('filters serialised correctly', () async { + List? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return reply(jsonEncode(_stubJson)); + }); + + await PersonalizationSdk().getProductsList( + params: const ProductsListParams( + filters: { + 'color': ['red', 'blue'], + 'size': ['M', 'L'], + }, + ), + ); + + final p = jsonDecode(captured![0] as String) as Map; + final filters = p['filters'] as Map; + expect(filters['color'], equals(['red', 'blue'])); + expect(filters['size'], equals(['M', 'L'])); + }); + }); + + // ------------------------------------------------------------------------- + // Response parsing + // ------------------------------------------------------------------------- + group('getProductsList — response parsing', () { + late PersonalizationSdk sdk; + + setUp(() { + sdk = PersonalizationSdk(); + stubOk(); + }); + + test('productsTotal parsed', () async { + final r = await sdk.getProductsList(); + expect(r.productsTotal, equals(42)); + }); + + test('product fields parsed correctly', () async { + final r = await sdk.getProductsList(); + expect(r.products.length, equals(1)); + final p = r.products.first; + expect(p.id, equals('prod-1')); + expect(p.name, equals('Test Product')); + expect(p.brand, equals('Test Brand')); + expect(p.price, equals(299.0)); + expect(p.priceFull, equals(399.0)); + expect(p.currency, equals('USD')); + expect(p.salesRate, equals(55)); + expect(p.resizedImages['120'], equals('https://img.test/120.jpg')); + }); + + test('product categories parsed', () async { + final r = await sdk.getProductsList(); + final cats = r.products.first.categories; + expect(cats.length, equals(1)); + expect(cats.first.id, equals('c1')); + expect(cats.first.name, equals('Electronics')); + }); + + test('priceRange parsed', () async { + final r = await sdk.getProductsList(); + expect(r.priceRange, isNotNull); + expect(r.priceRange!.min, equals(100.0)); + expect(r.priceRange!.max, equals(9999.0)); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + group('getProductsList — edge cases', () { + test('empty response defaults to safe values', () async { + stubOk({'products': [], 'products_total': 0}); + final r = await PersonalizationSdk().getProductsList(); + expect(r.products, isEmpty); + expect(r.productsTotal, equals(0)); + expect(r.priceRange, isNull); + }); + + test('missing keys default to empty list and zero', () async { + stubOk({}); + final r = await PersonalizationSdk().getProductsList(); + expect(r.products, isEmpty); + expect(r.productsTotal, equals(0)); + }); + + test('empty ProductsListParams serialises to empty JSON', () { + const p = ProductsListParams(); + final decoded = jsonDecode(p.toJson()) as Map; + expect(decoded, isEmpty); + }); + + test('empty filters map omitted from JSON', () { + const p = ProductsListParams(filters: {}); + final decoded = jsonDecode(p.toJson()) as Map; + expect(decoded.containsKey('filters'), isFalse); + }); + + test('iOS-style uniqid field NOT used — id expected', () async { + stubOk({ + 'products': [ + { + 'id': 'correct-id', + 'uniqid': 'wrong-id', + 'name': 'X', + 'brand': '', + 'description': '', + 'image_url': '', + 'url': '', + 'price': 0, + 'price_full': 0, + 'currency': '', + 'sales_rate': 0, + 'relative_sales_rate': 0, + }, + ], + 'products_total': 1, + }); + final r = await PersonalizationSdk().getProductsList(); + expect(r.products.first.id, equals('correct-id')); + }); + }); +} diff --git a/test/profile_test.dart b/test/profile_test.dart new file mode 100644 index 0000000..ebdf985 --- /dev/null +++ b/test/profile_test.dart @@ -0,0 +1,286 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +const _setProfileChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.setProfile'; +const _getSidChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid'; +const _getDidChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid'; + +// Push channels registered by PersonalizationFlutterApi.setUp in the SDK ctor. +const _onReceivedChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived'; +const _onDeliveredChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered'; +const _onClickedChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const codec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + void mockChannel(String channel, MessageHandler handler) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, handler); + + void unmockChannel(String channel) => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMessageHandler(channel, null); + + MessageHandler successHandler() => + (ByteData? _) async => codec.encodeMessage([]); + + setUp(() { + mockChannel(_onReceivedChannel, successHandler()); + mockChannel(_onDeliveredChannel, successHandler()); + mockChannel(_onClickedChannel, successHandler()); + mockChannel(_setProfileChannel, successHandler()); + mockChannel( + _getSidChannel, + (_) async => codec.encodeMessage(['sid-stub']), + ); + mockChannel( + _getDidChannel, + (_) async => codec.encodeMessage(['did-stub']), + ); + }); + + tearDown(() { + for (final ch in [ + _onReceivedChannel, + _onDeliveredChannel, + _onClickedChannel, + _setProfileChannel, + _getSidChannel, + _getDidChannel, + ]) { + unmockChannel(ch); + } + }); + + pigeon.ProfileParamsWire Function() captureWire() { + pigeon.ProfileParamsWire? captured; + mockChannel(_setProfileChannel, (ByteData? msg) async { + final args = codec.decodeMessage(msg) as List; + captured = args[0] as pigeon.ProfileParamsWire; + return codec.encodeMessage([]); + }); + return () => captured!; + } + + // --------------------------------------------------------------------------- + // toWire: field mapping + // --------------------------------------------------------------------------- + group('ProfileParams.toWire field mapping', () { + test('all string fields map to correct wire fields', () { + final wire = const ProfileParams( + email: 'user@example.com', + phone: '+79001234567', + loyaltyId: 'loyalty-42', + firstName: 'Ivan', + lastName: 'Petrov', + birthday: '1990-06-15', + location: 'Moscow', + advertisingId: 'adv-id-123', + fbId: 'fb-456', + vkId: 'vk-789', + telegramId: 'tg-000', + loyaltyCardLocation: 'store-1', + loyaltyStatus: 'gold', + userId: 'user-99', + ).toWire(); + + expect(wire.email, 'user@example.com'); + expect(wire.phone, '+79001234567'); + expect(wire.loyaltyId, 'loyalty-42'); + expect(wire.firstName, 'Ivan'); + expect(wire.lastName, 'Petrov'); + expect(wire.birthday, '1990-06-15'); + expect(wire.location, 'Moscow'); + expect(wire.advertisingId, 'adv-id-123'); + expect(wire.fbId, 'fb-456'); + expect(wire.vkId, 'vk-789'); + expect(wire.telegramId, 'tg-000'); + expect(wire.loyaltyCardLocation, 'store-1'); + expect(wire.loyaltyStatus, 'gold'); + expect(wire.userId, 'user-99'); + }); + + test('int and bool fields map correctly', () { + final wire = const ProfileParams( + age: 33, + loyaltyBonuses: 500, + loyaltyBonusesToNextLevel: 1000, + boughtSomething: true, + ).toWire(); + + expect(wire.age, 33); + expect(wire.loyaltyBonuses, 500); + expect(wire.loyaltyBonusesToNextLevel, 1000); + expect(wire.boughtSomething, true); + }); + + test('all fields null when ProfileParams is empty', () { + final wire = const ProfileParams().toWire(); + + expect(wire.email, isNull); + expect(wire.phone, isNull); + expect(wire.loyaltyId, isNull); + expect(wire.firstName, isNull); + expect(wire.lastName, isNull); + expect(wire.birthday, isNull); + expect(wire.age, isNull); + expect(wire.gender, isNull); + expect(wire.location, isNull); + expect(wire.advertisingId, isNull); + expect(wire.fbId, isNull); + expect(wire.vkId, isNull); + expect(wire.telegramId, isNull); + expect(wire.loyaltyCardLocation, isNull); + expect(wire.loyaltyStatus, isNull); + expect(wire.loyaltyBonuses, isNull); + expect(wire.loyaltyBonusesToNextLevel, isNull); + expect(wire.boughtSomething, isNull); + expect(wire.userId, isNull); + expect(wire.customPropertiesJson, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // toWire: gender enum + // --------------------------------------------------------------------------- + group('ProfileParams.toWire gender', () { + test('male → "m"', () { + expect( + const ProfileParams(gender: ProfileGender.male).toWire().gender, + 'm', + ); + }); + + test('female → "f"', () { + expect( + const ProfileParams(gender: ProfileGender.female).toWire().gender, + 'f', + ); + }); + + test('null gender → null wire gender', () { + expect(const ProfileParams().toWire().gender, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // toWire: customProperties JSON + // --------------------------------------------------------------------------- + group('ProfileParams.toWire customProperties', () { + test('null customProperties → null customPropertiesJson', () { + expect(const ProfileParams().toWire().customPropertiesJson, isNull); + }); + + test('customProperties serialized to valid JSON', () { + final wire = const ProfileParams( + customProperties: {'club': 'vip', 'score': 42}, + ).toWire(); + + expect(wire.customPropertiesJson, isNotNull); + final decoded = + jsonDecode(wire.customPropertiesJson!) as Map; + expect(decoded['club'], 'vip'); + expect(decoded['score'], 42); + }); + + test('customProperties with null value keeps key in JSON', () { + final wire = const ProfileParams( + customProperties: {'present': 'yes', 'absent': null}, + ).toWire(); + + final decoded = + jsonDecode(wire.customPropertiesJson!) as Map; + expect(decoded.containsKey('absent'), true); + expect(decoded['absent'], isNull); + }); + }); + + // --------------------------------------------------------------------------- + // setProfile channel + // --------------------------------------------------------------------------- + group('setProfile channel', () { + test('sends ProfileParamsWire at args[0]', () async { + final getWire = captureWire(); + await PersonalizationSdk().setProfile( + const ProfileParams(email: 'a@b.com', phone: '+7000'), + ); + expect(getWire().email, 'a@b.com'); + expect(getWire().phone, '+7000'); + }); + + test('empty ProfileParams does not throw', () { + final sdk = PersonalizationSdk(); + expect(() => sdk.setProfile(const ProfileParams()), returnsNormally); + }); + + test('gender is encoded as string in wire sent over channel', () async { + final getWire = captureWire(); + await PersonalizationSdk().setProfile( + const ProfileParams(gender: ProfileGender.female), + ); + expect(getWire().gender, 'f'); + }); + }); + + // --------------------------------------------------------------------------- + // getSid + // --------------------------------------------------------------------------- + group('getSid', () { + test('returns value from channel', () async { + mockChannel( + _getSidChannel, + (_) async => codec.encodeMessage(['session-abc-123']), + ); + final sid = await PersonalizationSdk().getSid(); + expect(sid, 'session-abc-123'); + }); + + test('returns different values on subsequent calls', () async { + var callCount = 0; + final responses = ['sid-first', 'sid-second']; + mockChannel(_getSidChannel, (_) async { + return codec.encodeMessage([responses[callCount++]]); + }); + final sdk = PersonalizationSdk(); + expect(await sdk.getSid(), 'sid-first'); + expect(await sdk.getSid(), 'sid-second'); + }); + }); + + // --------------------------------------------------------------------------- + // getDid + // --------------------------------------------------------------------------- + group('getDid', () { + test('returns device ID from channel', () async { + mockChannel( + _getDidChannel, + (_) async => codec.encodeMessage(['device-xyz-456']), + ); + final did = await PersonalizationSdk().getDid(); + expect(did, 'device-xyz-456'); + }); + + test('returns null when channel returns null', () async { + mockChannel( + _getDidChannel, + (_) async => codec.encodeMessage([null]), + ); + final did = await PersonalizationSdk().getDid(); + expect(did, isNull); + }); + }); +} diff --git a/test/push_notification_callbacks_test.dart b/test/push_notification_callbacks_test.dart new file mode 100644 index 0000000..d1637af --- /dev/null +++ b/test/push_notification_callbacks_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/src/push/push_notification_callbacks.dart'; + +void main() { + group('PushNotificationCallbacks', () { + late PushNotificationCallbacks sut; + + setUp(() { + sut = PushNotificationCallbacks(); + }); + + group('dispatch', () { + test('onPushReceived forwards to registered callback', () { + Map? got; + sut.setCallbacks(onReceived: (p) => got = p); + + sut.onPushReceived({'type': 'promo', 'id': null}); + + expect(got, {'type': 'promo', 'id': null}); + }); + + test('onPushDelivered forwards to registered callback', () { + Map? got; + sut.setCallbacks(onDelivered: (p) => got = p); + + sut.onPushDelivered({'id': '42'}); + + expect(got, {'id': '42'}); + }); + + test('onPushClicked forwards to registered callback', () { + Map? got; + sut.setCallbacks(onClicked: (p) => got = p); + + sut.onPushClicked({ + 'id': '7', + 'actionIdentifier': 'com.apple.UNNotificationDefaultActionIdentifier', + }); + + expect(got!['id'], '7'); + expect(got!['actionIdentifier'], isNotNull); + }); + }); + + group('null safety', () { + test('does not throw when no callback registered for received', () { + expect(() => sut.onPushReceived({'k': 'v'}), returnsNormally); + }); + + test('does not throw when no callback registered for delivered', () { + expect(() => sut.onPushDelivered({'k': 'v'}), returnsNormally); + }); + + test('does not throw when no callback registered for clicked', () { + expect(() => sut.onPushClicked({'k': 'v'}), returnsNormally); + }); + }); + + group('copy semantics', () { + test('received map is a copy — mutating original does not affect it', () { + Map? got; + sut.setCallbacks(onReceived: (p) => got = p); + + final original = {'key': 'before'}; + sut.onPushReceived(original); + original['key'] = 'after'; + + expect(got!['key'], 'before'); + }); + + test('delivered map is a copy', () { + Map? got; + sut.setCallbacks(onDelivered: (p) => got = p); + + final original = {'key': 'before'}; + sut.onPushDelivered(original); + original['key'] = 'after'; + + expect(got!['key'], 'before'); + }); + + test('clicked map is a copy', () { + Map? got; + sut.setCallbacks(onClicked: (p) => got = p); + + final original = {'key': 'before'}; + sut.onPushClicked(original); + original['key'] = 'after'; + + expect(got!['key'], 'before'); + }); + }); + + group('setCallbacks replaces', () { + test('second setCallbacks replaces first for same event', () { + int first = 0; + int second = 0; + sut.setCallbacks(onReceived: (_) => first++); + sut.onPushReceived({}); + sut.setCallbacks(onReceived: (_) => second++); + sut.onPushReceived({}); + + expect(first, 1); + expect(second, 1); + }); + + test('each event type dispatches independently', () { + Map? received; + Map? delivered; + sut.setCallbacks( + onReceived: (p) => received = p, + onDelivered: (p) => delivered = p, + ); + + sut.onPushReceived({'src': 'recv'}); + sut.onPushDelivered({'src': 'dlvr'}); + + expect(received!['src'], 'recv'); + expect(delivered!['src'], 'dlvr'); + }); + }); + }); +} diff --git a/test/recommendation_test.dart b/test/recommendation_test.dart new file mode 100644 index 0000000..d3b961a --- /dev/null +++ b/test/recommendation_test.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +const _getRecommendationChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getRecommendation'; +const _onReceivedChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived'; +const _onDeliveredChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered'; +const _onClickedChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked'; + +const _stubJson = ''' +{ + "title": "You may like", + "recommends": [ + { + "id": "prod-1", + "name": "Widget Pro", + "brand": "Acme", + "description": "A great widget", + "image_url": "https://img/1.jpg", + "picture": "https://img/1-sm.jpg", + "image_url_resized": {"120": "https://img/1-120.jpg", "310": "https://img/1-310.jpg"}, + "url": "https://shop/prod-1", + "price": 99.99, + "price_full": 120.0, + "price_formatted": "99.99 RUB", + "price_full_formatted": "120.00 RUB", + "currency": "RUB", + "sales_rate": 42, + "relative_sales_rate": 0.75, + "categories": [ + {"id": "cat-1", "name": "Gadgets", "parent_id": "cat-0", "url": "/gadgets"} + ] + } + ] +} +'''; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const codec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + void mockChannel(String channel, MessageHandler handler) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, handler); + + void unmockChannel(String channel) => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMessageHandler(channel, null); + + MessageHandler successHandler() => + (ByteData? _) async => codec.encodeMessage([]); + + MessageHandler jsonHandler(String json) => + (ByteData? _) async => codec.encodeMessage([json]); + + setUp(() { + mockChannel(_onReceivedChannel, successHandler()); + mockChannel(_onDeliveredChannel, successHandler()); + mockChannel(_onClickedChannel, successHandler()); + mockChannel(_getRecommendationChannel, jsonHandler(_stubJson)); + }); + + tearDown(() { + for (final ch in [ + _onReceivedChannel, + _onDeliveredChannel, + _onClickedChannel, + _getRecommendationChannel, + ]) { + unmockChannel(ch); + } + }); + + ({List args, String result}) Function() captureCall() { + List? capturedArgs; + String? capturedResult; + mockChannel(_getRecommendationChannel, (ByteData? msg) async { + capturedArgs = codec.decodeMessage(msg) as List; + capturedResult = _stubJson; + return codec.encodeMessage([_stubJson]); + }); + return () => (args: capturedArgs!, result: capturedResult!); + } + + // --------------------------------------------------------------------------- + // validation + // --------------------------------------------------------------------------- + group('getRecommendation validation', () { + test('empty code throws ArgumentError synchronously', () { + expect( + () => PersonalizationSdk().getRecommendation(''), + throwsArgumentError, + ); + }); + + test('non-empty code does not throw synchronously', () { + expect( + () => PersonalizationSdk().getRecommendation('block-1'), + returnsNormally, + ); + }); + }); + + // --------------------------------------------------------------------------- + // channel args + // --------------------------------------------------------------------------- + group('getRecommendation channel args', () { + test('sends code at args[0]', () async { + final capture = captureCall(); + await PersonalizationSdk().getRecommendation('block-42'); + expect(capture().args[0], 'block-42'); + }); + + test('null paramsJson at args[1] when no params passed', () async { + final capture = captureCall(); + await PersonalizationSdk().getRecommendation('block-1'); + expect(capture().args[1], isNull); + }); + + test('paramsJson is valid JSON when params passed', () async { + final capture = captureCall(); + await PersonalizationSdk().getRecommendation( + 'block-1', + params: const RecommendationParams( + itemId: 'item-99', + categoryId: 'cat-5', + locations: 'loc1,loc2', + imageSize: 310, + withLocations: true, + ), + ); + final json = + jsonDecode(capture().args[1] as String) as Map; + expect(json['item_id'], 'item-99'); + expect(json['category_id'], 'cat-5'); + expect(json['locations'], 'loc1,loc2'); + expect(json['image_size'], 310); + expect(json['with_locations'], true); + }); + }); + + // --------------------------------------------------------------------------- + // response parsing + // --------------------------------------------------------------------------- + group('RecommendationResponse.fromJson', () { + late RecommendationResponse response; + + setUp(() async { + response = await PersonalizationSdk().getRecommendation('block-1'); + }); + + test('title parsed correctly', () { + expect(response.title, 'You may like'); + }); + + test('products list has correct length', () { + expect(response.products, hasLength(1)); + }); + + test('product fields parsed correctly', () { + final p = response.products.first; + expect(p.id, 'prod-1'); + expect(p.name, 'Widget Pro'); + expect(p.brand, 'Acme'); + expect(p.description, 'A great widget'); + expect(p.imageUrl, 'https://img/1.jpg'); + expect(p.resizedImageUrl, 'https://img/1-sm.jpg'); + expect(p.url, 'https://shop/prod-1'); + expect(p.price, 99.99); + expect(p.priceFull, 120.0); + expect(p.priceFormatted, '99.99 RUB'); + expect(p.priceFullFormatted, '120.00 RUB'); + expect(p.currency, 'RUB'); + expect(p.salesRate, 42); + expect(p.relativeSalesRate, 0.75); + }); + + test('resizedImages parsed as map', () { + final images = response.products.first.resizedImages; + expect(images['120'], 'https://img/1-120.jpg'); + expect(images['310'], 'https://img/1-310.jpg'); + }); + + test('categories parsed correctly', () { + final cat = response.products.first.categories.first; + expect(cat.id, 'cat-1'); + expect(cat.name, 'Gadgets'); + expect(cat.parentId, 'cat-0'); + expect(cat.url, '/gadgets'); + }); + }); + + // --------------------------------------------------------------------------- + // edge cases + // --------------------------------------------------------------------------- + group('RecommendationResponse.fromJson edge cases', () { + RecommendationResponse parseJson(String json) => + RecommendationResponse.fromJson( + jsonDecode(json) as Map, + ); + + test('missing title defaults to empty string', () { + final r = parseJson('{"recommends": []}'); + expect(r.title, ''); + }); + + test('missing recommends defaults to empty list', () { + final r = parseJson('{"title": "x"}'); + expect(r.products, isEmpty); + }); + + test('optional price fields null when absent', () { + final r = parseJson( + '{"recommends": [{"id":"1","name":"","brand":"",' + '"description":"","image_url":"","picture":"",' + '"url":"","price":0,"price_full":0,"currency":"",' + '"sales_rate":0,"relative_sales_rate":0,"categories":[]}]}', + ); + expect(r.products.first.priceFormatted, isNull); + expect(r.products.first.priceFullFormatted, isNull); + }); + + test('category optional fields null when absent', () { + final r = parseJson( + '{"recommends": [{"id":"1","name":"","brand":"","description":"",' + '"image_url":"","picture":"","url":"","price":0,"price_full":0,' + '"currency":"","sales_rate":0,"relative_sales_rate":0,' + '"categories": [{"id": "c1", "name": "Books"}]}]}', + ); + final cat = r.products.first.categories.first; + expect(cat.parentId, isNull); + expect(cat.url, isNull); + }); + }); +} diff --git a/test/rees46_flutter_sdk_method_channel_test.dart b/test/rees46_flutter_sdk_method_channel_test.dart new file mode 100644 index 0000000..37b40f9 --- /dev/null +++ b/test/rees46_flutter_sdk_method_channel_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/src/personalization_sdk.dart'; + +void main() { + test('compiles', () { + expect(PersonalizationSdk, isNotNull); + }); +} diff --git a/test/rees46_flutter_sdk_test.dart b/test/rees46_flutter_sdk_test.dart new file mode 100644 index 0000000..37b40f9 --- /dev/null +++ b/test/rees46_flutter_sdk_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/src/personalization_sdk.dart'; + +void main() { + test('compiles', () { + expect(PersonalizationSdk, isNotNull); + }); +} diff --git a/test/sdk_init_handler_test.dart b/test/sdk_init_handler_test.dart new file mode 100644 index 0000000..aa5d930 --- /dev/null +++ b/test/sdk_init_handler_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/src/init/sdk_init_handler.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; +import 'package:personalization_flutter_sdk/src/sdk_init_config.dart'; + +const _initChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.initialize'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late pigeon.InitConfig captured; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_initChannel, (ByteData? message) async { + final args = + pigeon.PersonalizationHostApi.pigeonChannelCodec.decodeMessage( + message, + ) + as List; + captured = args[0] as pigeon.InitConfig; + return pigeon.PersonalizationHostApi.pigeonChannelCodec.encodeMessage( + [], + ); + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_initChannel, null); + debugDefaultTargetPlatformOverride = null; + }); + + Future init(SdkInitConfig config) => + SdkInitHandler().initialize(config); + + group('apiDomain', () { + test('null → api.rees46.ru', () async { + await init(const SdkInitConfig(shopId: 's')); + expect(captured.apiDomain, 'api.rees46.ru'); + }); + + test('blank → api.rees46.ru', () async { + await init(const SdkInitConfig(shopId: 's', apiDomain: ' ')); + expect(captured.apiDomain, 'api.rees46.ru'); + }); + + test('provided → trimmed', () async { + await init( + const SdkInitConfig(shopId: 's', apiDomain: ' custom.api.com '), + ); + expect(captured.apiDomain, 'custom.api.com'); + }); + }); + + group('stream', () { + test('null on Android → android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + await init(const SdkInitConfig(shopId: 's')); + expect(captured.stream, 'android'); + }); + + test('null on iOS → ios', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await init(const SdkInitConfig(shopId: 's')); + expect(captured.stream, 'ios'); + }); + + test('blank → platform default', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + await init(const SdkInitConfig(shopId: 's', stream: '')); + expect(captured.stream, 'android'); + }); + + test('provided → trimmed', () async { + await init(const SdkInitConfig(shopId: 's', stream: ' web ')); + expect(captured.stream, 'web'); + }); + }); + + group('bool defaults', () { + test('enableLogs null → false', () async { + await init(const SdkInitConfig(shopId: 's')); + expect(captured.enableLogs, false); + }); + + test('enableLogs true → true', () async { + await init(const SdkInitConfig(shopId: 's', enableLogs: true)); + expect(captured.enableLogs, true); + }); + + test('autoSendPushToken null → true', () async { + await init(const SdkInitConfig(shopId: 's')); + expect(captured.autoSendPushToken, true); + }); + + test('autoSendPushToken false → false', () async { + await init(const SdkInitConfig(shopId: 's', autoSendPushToken: false)); + expect(captured.autoSendPushToken, false); + }); + + test('sendAdvertisingId null → false', () async { + await init(const SdkInitConfig(shopId: 's')); + expect(captured.sendAdvertisingId, false); + }); + + test('enableAutoPopupPresentation null → true', () async { + await init(const SdkInitConfig(shopId: 's')); + expect(captured.enableAutoPopupPresentation, true); + }); + + test('needReInitialization null → false', () async { + await init(const SdkInitConfig(shopId: 's')); + expect(captured.needReInitialization, false); + }); + }); + + test('shopId passed through unchanged', () async { + await init(const SdkInitConfig(shopId: 'my_shop_123')); + expect(captured.shopId, 'my_shop_123'); + }); +} diff --git a/test/search_blank_test.dart b/test/search_blank_test.dart new file mode 100644 index 0000000..9f2f4c6 --- /dev/null +++ b/test/search_blank_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +const _stubJson = { + 'products': [ + { + 'id': 'blank-1', + 'name': 'Trending Product', + 'brand': 'Hot Brand', + 'description': 'Popular item', + 'image_url': 'https://img.test/b1.jpg', + 'url': 'https://shop.test/b1', + 'price': 499.0, + 'price_full': 599.0, + 'price_formatted': r'$499', + 'price_full_formatted': r'$599', + 'currency': 'USD', + 'sales_rate': 88, + 'relative_sales_rate': 0.9, + 'image_url_resized': {'120': 'https://img.test/b1-120.jpg'}, + }, + ], + 'suggests': [ + {'name': 'phones', 'url': '/search?q=phones'}, + {'name': 'laptops', 'url': '/search?q=laptops'}, + ], +}; + +const _channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchBlank'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final messageCodec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + ByteData reply(String value) => messageCodec.encodeMessage([value])!; + + void stubOk() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply(jsonEncode(_stubJson)), + ); + } + + // ------------------------------------------------------------------------- + // Response parsing + // ------------------------------------------------------------------------- + group('searchBlank — response parsing', () { + late PersonalizationSdk sdk; + + setUp(() { + sdk = PersonalizationSdk(); + stubOk(); + }); + + test('products list parsed correctly', () async { + final r = await sdk.searchBlank(); + expect(r.products.length, equals(1)); + final p = r.products.first; + expect(p.id, equals('blank-1')); + expect(p.name, equals('Trending Product')); + expect(p.price, equals(499.0)); + expect(p.salesRate, equals(88)); + expect(p.resizedImages['120'], equals('https://img.test/b1-120.jpg')); + }); + + test('suggests list parsed correctly', () async { + final r = await sdk.searchBlank(); + expect(r.suggests.length, equals(2)); + expect(r.suggests.first.name, equals('phones')); + expect(r.suggests.first.url, equals('/search?q=phones')); + expect(r.suggests.last.name, equals('laptops')); + }); + + test('returns SearchBlankResponse type', () async { + final r = await sdk.searchBlank(); + expect(r, isA()); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + group('searchBlank — edge cases', () { + test('empty products and suggests default to empty lists', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply( + jsonEncode({'products': [], 'suggests': []}), + ), + ); + + final r = await PersonalizationSdk().searchBlank(); + expect(r.products, isEmpty); + expect(r.suggests, isEmpty); + }); + + test('missing keys default to empty lists', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply(jsonEncode({})), + ); + + final r = await PersonalizationSdk().searchBlank(); + expect(r.products, isEmpty); + expect(r.suggests, isEmpty); + }); + + test('iOS-only fields in JSON are silently ignored', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply( + jsonEncode({ + 'products': [], + 'suggests': [ + { + 'name': 'shoes', + 'url': '/search?q=shoes', + 'deeplink_ios': 'myapp://search?q=shoes', // iOS-only + }, + ], + 'last_queries': [], // iOS-only + 'last_products': true, // iOS-only + }), + ), + ); + + final r = await PersonalizationSdk().searchBlank(); + expect(r.suggests.first.name, equals('shoes')); + expect(r.suggests.first.url, equals('/search?q=shoes')); + // No crash from extra iOS fields. + }); + }); +} diff --git a/test/search_instant_test.dart b/test/search_instant_test.dart new file mode 100644 index 0000000..56c7827 --- /dev/null +++ b/test/search_instant_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +const _stubJson = { + 'products': [ + { + 'id': 'instant-1', + 'name': 'Instant Phone', + 'brand': 'FastBrand', + 'description': 'Quick result', + 'image_url': 'https://img.test/i1.jpg', + 'url': 'https://shop.test/i1', + 'price': 299.0, + 'price_full': 349.0, + 'price_formatted': r'$299', + 'price_full_formatted': r'$349', + 'currency': 'USD', + 'sales_rate': 10, + 'relative_sales_rate': 0.5, + 'image_url_resized': {'120': 'https://img.test/i1-120.jpg'}, + }, + ], + 'categories': [ + { + 'id': 'c1', + 'name': 'Phones', + 'url': '/phones', + 'parent': null, + 'count': 5, + }, + ], + 'products_total': 7, + 'locations': [ + {'id': 'l1', 'name': 'SPb', 'type': null}, + ], +}; + +const _channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchInstant'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final messageCodec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + ByteData reply(Object? value) => + messageCodec.encodeMessage([value as String])!; + + void stubOk() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply(jsonEncode(_stubJson)), + ); + } + + // ------------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------------- + group('searchInstant — validation', () { + test('empty query throws ArgumentError', () { + final sdk = PersonalizationSdk(); + expect(() => sdk.searchInstant(''), throwsArgumentError); + }); + + test('non-empty query does not throw synchronously', () { + stubOk(); + final sdk = PersonalizationSdk(); + expect(() => sdk.searchInstant('ph'), returnsNormally); + }); + }); + + // ------------------------------------------------------------------------- + // Channel arguments + // ------------------------------------------------------------------------- + group('searchInstant — channel args', () { + test('query forwarded correctly', () async { + List? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return reply(jsonEncode(_stubJson)); + }); + + await PersonalizationSdk().searchInstant('iphone'); + expect(captured![0], equals('iphone')); + }); + + test('null paramsJson when no SearchInstantParams', () async { + List? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return reply(jsonEncode(_stubJson)); + }); + + await PersonalizationSdk().searchInstant('iphone'); + expect(captured![1], isNull); + }); + + test('SearchInstantParams serialised into paramsJson', () async { + List? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(_channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return reply(jsonEncode(_stubJson)); + }); + + await PersonalizationSdk().searchInstant( + 'iphone', + params: const SearchInstantParams( + locations: 'msk', + excludedBrands: ['Samsung'], + ), + ); + + final p = jsonDecode(captured![1] as String) as Map; + expect(p['locations'], equals('msk')); + expect(p['excluded_brands'], equals(['Samsung'])); + }); + }); + + // ------------------------------------------------------------------------- + // Response parsing + // ------------------------------------------------------------------------- + group('searchInstant — response parsing', () { + late PersonalizationSdk sdk; + + setUp(() { + sdk = PersonalizationSdk(); + stubOk(); + }); + + test('productsTotal parsed', () async { + final r = await sdk.searchInstant('ph'); + expect(r.productsTotal, equals(7)); + }); + + test('products list parsed', () async { + final r = await sdk.searchInstant('ph'); + expect(r.products.length, equals(1)); + expect(r.products.first.id, equals('instant-1')); + expect(r.products.first.price, equals(299.0)); + }); + + test('categories list parsed', () async { + final r = await sdk.searchInstant('ph'); + expect(r.categories.first.name, equals('Phones')); + expect(r.categories.first.count, equals(5)); + }); + + test('locations parsed', () async { + final r = await sdk.searchInstant('ph'); + expect(r.locations!.first.name, equals('SPb')); + expect(r.locations!.first.type, isNull); + }); + + test('no priceRange field on SearchInstantResponse', () async { + final r = await sdk.searchInstant('ph'); + // SearchInstantResponse has no priceRange — verify it is not exposed + expect(r, isA()); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + group('searchInstant — edge cases', () { + test('empty response defaults to safe values', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + _channel, + (_) async => reply( + jsonEncode({ + 'products': [], + 'categories': [], + 'products_total': 0, + }), + ), + ); + + final r = await PersonalizationSdk().searchInstant('x'); + expect(r.products, isEmpty); + expect(r.productsTotal, equals(0)); + expect(r.locations, isNull); + }); + + test('empty SearchInstantParams produces empty JSON object', () { + const p = SearchInstantParams(); + final decoded = jsonDecode(p.toJson()) as Map; + expect(decoded, isEmpty); + }); + + test('excludedBrands empty list omitted from JSON', () { + const p = SearchInstantParams(excludedBrands: []); + final decoded = jsonDecode(p.toJson()) as Map; + expect(decoded.containsKey('excluded_brands'), isFalse); + }); + }); +} diff --git a/test/search_test.dart b/test/search_test.dart new file mode 100644 index 0000000..acf3312 --- /dev/null +++ b/test/search_test.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:personalization_flutter_sdk/personalization_flutter_sdk.dart'; +import 'package:personalization_flutter_sdk/src/pigeon/personalization_api.g.dart' + as pigeon; + +const _stubJson = { + 'products': [ + { + 'id': 'prod-1', + 'name': 'Test Phone', + 'brand': 'Brand X', + 'description': 'A great phone', + 'image_url': 'https://img.test/1.jpg', + 'url': 'https://shop.test/1', + 'price': 199.99, + 'price_full': 249.99, + 'price_formatted': r'$199.99', + 'price_full_formatted': r'$249.99', + 'currency': 'USD', + 'sales_rate': 42, + 'relative_sales_rate': 0.75, + 'image_url_resized': { + '120': 'https://img.test/120.jpg', + '520': 'https://img.test/520.jpg', + }, + }, + ], + 'categories': [ + { + 'id': 'cat-1', + 'name': 'Electronics', + 'url': '/electronics', + 'parent': 'root', + 'count': 150, + }, + ], + 'products_total': 42, + 'price_range': {'min': 99.0, 'max': 9999.0}, + 'locations': [ + {'id': 'loc-1', 'name': 'Moscow', 'type': 'city'}, + ], +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late ByteData Function(String) codec; + final messageCodec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + + setUp(() { + codec = (String json) => messageCodec.encodeMessage([json])!; + }); + + void stubSearchFull(Object? result) { + const channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull'; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + channel, + (_) async => codec(jsonEncode(_stubJson)), + ); + if (result != null) { + // Override with custom response. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (_) async => codec(result as String)); + } + } + + // ------------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------------- + group('searchFull — validation', () { + test('empty query throws ArgumentError', () { + final sdk = PersonalizationSdk(); + expect(() => sdk.searchFull(''), throwsArgumentError); + }); + + test('non-empty query does not throw synchronously', () { + final sdk = PersonalizationSdk(); + stubSearchFull(null); + expect(() => sdk.searchFull('phone'), returnsNormally); + }); + }); + + // ------------------------------------------------------------------------- + // Channel arguments + // ------------------------------------------------------------------------- + group('searchFull — channel args', () { + test('query is forwarded as-is', () async { + List? captured; + const channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull'; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return codec(jsonEncode(_stubJson)); + }); + + final sdk = PersonalizationSdk(); + await sdk.searchFull('laptop'); + expect(captured![0], equals('laptop')); + }); + + test('null paramsJson when no SearchParams supplied', () async { + List? captured; + const channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull'; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return codec(jsonEncode(_stubJson)); + }); + + final sdk = PersonalizationSdk(); + await sdk.searchFull('laptop'); + expect(captured![1], isNull); + }); + + test('SearchParams serialised into paramsJson', () async { + List? captured; + const channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull'; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (msg) async { + captured = messageCodec.decodeMessage(msg) as List; + return codec(jsonEncode(_stubJson)); + }); + + final sdk = PersonalizationSdk(); + await sdk.searchFull( + 'phone', + params: const SearchParams( + limit: 20, + page: 2, + sortBy: 'price', + sortDir: 'asc', + priceMin: 100.0, + priceMax: 5000.0, + categories: ['1', '2'], + colors: ['red', 'blue'], + ), + ); + + final paramsStr = captured![1] as String; + final params = jsonDecode(paramsStr) as Map; + expect(params['limit'], equals(20)); + expect(params['page'], equals(2)); + expect(params['sort_by'], equals('price')); + expect(params['sort_dir'], equals('asc')); + expect(params['price_min'], equals(100.0)); + expect(params['price_max'], equals(5000.0)); + expect(params['categories'], equals(['1', '2'])); + expect(params['colors'], equals(['red', 'blue'])); + }); + }); + + // ------------------------------------------------------------------------- + // Response parsing + // ------------------------------------------------------------------------- + group('searchFull — response parsing', () { + late PersonalizationSdk sdk; + + setUp(() { + sdk = PersonalizationSdk(); + stubSearchFull(null); + }); + + test('productsTotal parsed correctly', () async { + final r = await sdk.searchFull('phone'); + expect(r.productsTotal, equals(42)); + }); + + test('products list parsed correctly', () async { + final r = await sdk.searchFull('phone'); + expect(r.products.length, equals(1)); + final p = r.products.first; + expect(p.id, equals('prod-1')); + expect(p.name, equals('Test Phone')); + expect(p.brand, equals('Brand X')); + expect(p.price, equals(199.99)); + expect(p.currency, equals('USD')); + expect(p.salesRate, equals(42)); + expect(p.resizedImages['120'], equals('https://img.test/120.jpg')); + }); + + test('categories list parsed correctly', () async { + final r = await sdk.searchFull('phone'); + expect(r.categories.length, equals(1)); + final c = r.categories.first; + expect(c.id, equals('cat-1')); + expect(c.name, equals('Electronics')); + expect(c.parentId, equals('root')); + expect(c.count, equals(150)); + }); + + test('priceRange parsed correctly', () async { + final r = await sdk.searchFull('phone'); + expect(r.priceRange, isNotNull); + expect(r.priceRange!.min, equals(99.0)); + expect(r.priceRange!.max, equals(9999.0)); + }); + + test('locations parsed correctly', () async { + final r = await sdk.searchFull('phone'); + expect(r.locations, isNotNull); + expect(r.locations!.length, equals(1)); + final loc = r.locations!.first; + expect(loc.id, equals('loc-1')); + expect(loc.name, equals('Moscow')); + expect(loc.type, equals('city')); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + group('searchFull — edge cases', () { + test('missing optional fields default to safe values', () async { + const channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull'; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + channel, + (_) async => codec( + jsonEncode({ + 'products': [], + 'categories': [], + 'products_total': 0, + }), + ), + ); + + final sdk = PersonalizationSdk(); + final r = await sdk.searchFull('phone'); + expect(r.products, isEmpty); + expect(r.categories, isEmpty); + expect(r.productsTotal, equals(0)); + expect(r.priceRange, isNull); + expect(r.locations, isNull); + }); + + test('product with null optional fields is parsed without error', () async { + const channel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull'; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler( + channel, + (_) async => codec( + jsonEncode({ + 'products': [ + { + 'id': 'x', + 'name': 'X', + 'brand': '', + 'description': '', + 'image_url': '', + 'url': '', + 'price': 0, + 'price_full': 0, + 'currency': '', + 'sales_rate': 0, + 'relative_sales_rate': 0, + }, + ], + 'categories': [], + 'products_total': 1, + }), + ), + ); + + final sdk = PersonalizationSdk(); + final r = await sdk.searchFull('x'); + expect(r.products.first.priceFormatted, isNull); + expect(r.products.first.resizedImages, isEmpty); + }); + + test('empty SearchParams sends empty JSON object', () { + const p = SearchParams(); + final decoded = jsonDecode(p.toJson()) as Map; + expect(decoded, isEmpty); + }); + + test('SearchParams with empty lists omits those keys', () { + const p = SearchParams(categories: [], colors: []); + final decoded = jsonDecode(p.toJson()) as Map; + expect(decoded.containsKey('categories'), isFalse); + expect(decoded.containsKey('colors'), isFalse); + }); + }); +} diff --git a/tool/apply_brand.dart b/tool/apply_brand.dart new file mode 100644 index 0000000..e798f9c --- /dev/null +++ b/tool/apply_brand.dart @@ -0,0 +1,233 @@ +import 'dart:io'; + +/// Applies brand-specific package metadata changes. +/// +/// Usage: +/// - BRAND=rees46 dart run tool/apply_brand.dart +/// - BRAND=personaclick dart run tool/apply_brand.dart +/// - BRAND=personalization dart run tool/apply_brand.dart +/// +/// Defaults to BRAND=personalization. +void main(List args) { + final brand = (Platform.environment['BRAND'] ?? 'personalization').trim(); + final targetPackageName = switch (brand.toLowerCase()) { + 'rees46' => 'rees46_flutter_sdk', + 'personaclick' => 'personaclick_flutter_sdk', + 'personalization' => 'personalization_flutter_sdk', + _ => _fail( + 'Unknown BRAND="$brand". Use: personalization | rees46 | personaclick', + ), + }; + final wrapperEntry = switch (brand.toLowerCase()) { + 'rees46' => _WrapperEntry( + fileName: 'lib/rees46_flutter_sdk.dart', + className: 'Rees46FlutterSdk', + configAlias: 'Rees46InitConfig', + otherWrapperToDelete: 'lib/personaclick_flutter_sdk.dart', + ), + 'personaclick' => _WrapperEntry( + fileName: 'lib/personaclick_flutter_sdk.dart', + className: 'PersonaclickFlutterSdk', + configAlias: 'PersonaclickInitConfig', + otherWrapperToDelete: 'lib/rees46_flutter_sdk.dart', + ), + // For internal/default package keep both wrappers present. + 'personalization' => null, + _ => null, + }; + + _patchPubspecName(path: File('pubspec.yaml'), newName: targetPackageName); + + _patchExamplePubspecDependency( + path: File('example/pubspec.yaml'), + oldNames: const [ + 'rees46_flutter_sdk', + 'personaclick_flutter_sdk', + 'personalization_flutter_sdk', + ], + newName: targetPackageName, + ); + + _patchDartImports( + root: Directory('.'), + oldNames: const [ + 'rees46_flutter_sdk', + 'personaclick_flutter_sdk', + 'personalization_flutter_sdk', + ], + newName: targetPackageName, + ); + + if (wrapperEntry != null) { + _generateWrapper(wrapperEntry); + _deleteIfExists(wrapperEntry.otherWrapperToDelete); + _patchExampleMainImport( + path: File('example/lib/main.dart'), + packageName: targetPackageName, + wrapperFileBasename: wrapperEntry.fileName.split('/').last, + ); + _patchIntegrationTestImport( + path: File('example/integration_test/plugin_integration_test.dart'), + packageName: targetPackageName, + wrapperFileBasename: wrapperEntry.fileName.split('/').last, + wrapperClassName: wrapperEntry.className, + ); + _patchReadmeImport( + path: File('README.md'), + packageName: targetPackageName, + wrapperFileBasename: wrapperEntry.fileName.split('/').last, + ); + } + + stdout.writeln('Applied BRAND="$brand" -> name: $targetPackageName'); +} + +Never _fail(String message) { + stderr.writeln(message); + exitCode = 2; + throw StateError(message); +} + +void _patchPubspecName({required File path, required String newName}) { + final content = path.readAsStringSync(); + final replaced = content.replaceFirstMapped( + RegExp(r'^name:\s*(.+)\s*$', multiLine: true), + (_) => 'name: $newName', + ); + path.writeAsStringSync(replaced); +} + +void _patchExamplePubspecDependency({ + required File path, + required List oldNames, + required String newName, +}) { + var content = path.readAsStringSync(); + + // Replace dependency key under "dependencies:". + for (final old in oldNames) { + content = content.replaceAll( + RegExp('^\\s{2}$old:\\s*\$', multiLine: true), + ' $newName:', + ); + } + + // Replace occurrences in description text. + for (final old in oldNames) { + content = content.replaceAll(old, newName); + } + + path.writeAsStringSync(content); +} + +void _patchDartImports({ + required Directory root, + required List oldNames, + required String newName, +}) { + final files = root + .listSync(recursive: true, followLinks: false) + .whereType() + .where((f) => f.path.endsWith('.dart')) + .where( + (f) => !f.path.contains( + '${Platform.pathSeparator}.dart_tool${Platform.pathSeparator}', + ), + ) + .where( + (f) => !f.path.contains( + '${Platform.pathSeparator}build${Platform.pathSeparator}', + ), + ) + .toList(); + + for (final file in files) { + var content = file.readAsStringSync(); + for (final old in oldNames) { + content = content.replaceAll('package:$old/', 'package:$newName/'); + } + file.writeAsStringSync(content); + } +} + +class _WrapperEntry { + final String fileName; + final String className; + final String configAlias; + final String otherWrapperToDelete; + + const _WrapperEntry({ + required this.fileName, + required this.className, + required this.configAlias, + required this.otherWrapperToDelete, + }); +} + +void _generateWrapper(_WrapperEntry entry) { + final file = File(entry.fileName); + file.writeAsStringSync(''' +import 'src/personalization_sdk.dart'; +import 'src/sdk_init_config.dart'; + +export 'src/sdk_init_config.dart' show SdkInitConfig; +export 'src/tracking/purchase_line_item.dart' show PurchaseLineItem; + +typedef ${entry.configAlias} = SdkInitConfig; + +class ${entry.className} extends PersonalizationSdk {} +'''); +} + +void _deleteIfExists(String path) { + final file = File(path); + if (file.existsSync()) { + file.deleteSync(); + } +} + +void _patchExampleMainImport({ + required File path, + required String packageName, + required String wrapperFileBasename, +}) { + var content = path.readAsStringSync(); + // Replace any import of package:/_flutter_sdk.dart + content = content.replaceAll( + RegExp(r"import 'package:[^/]+/[^']+_flutter_sdk\.dart';"), + "import 'package:$packageName/$wrapperFileBasename';", + ); + path.writeAsStringSync(content); +} + +void _patchIntegrationTestImport({ + required File path, + required String packageName, + required String wrapperFileBasename, + required String wrapperClassName, +}) { + var content = path.readAsStringSync(); + content = content.replaceAll( + RegExp(r"import 'package:[^/]+/[^']+_flutter_sdk\.dart';"), + "import 'package:$packageName/$wrapperFileBasename';", + ); + // Also patch the class usage line if needed. + content = content.replaceAll( + RegExp(r'final\s+\w+\s+plugin\s*=\s*\w+\(\);'), + 'final $wrapperClassName plugin = $wrapperClassName();', + ); + path.writeAsStringSync(content); +} + +void _patchReadmeImport({ + required File path, + required String packageName, + required String wrapperFileBasename, +}) { + var content = path.readAsStringSync(); + content = content.replaceAll( + RegExp(r"import 'package:[^/]+/[^']+_flutter_sdk\.dart';"), + "import 'package:$packageName/$wrapperFileBasename';", + ); + path.writeAsStringSync(content); +}