From 4199f02bc9ef0d5305c9f5aadddfb8d89ac1ee3d Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Thu, 25 Jun 2026 14:21:08 +0400 Subject: [PATCH 01/12] feat: add loyalty methods (joinLoyalty, getLoyaltyStatus) DEV-3362 --- .github/workflows/version.yaml | 76 ++++++++++++++++++++++++++++++++++ .releaserc.yml | 14 +++++++ scripts/apply_release.sh | 33 +++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 .github/workflows/version.yaml create mode 100644 .releaserc.yml create mode 100755 scripts/apply_release.sh diff --git a/.github/workflows/version.yaml b/.github/workflows/version.yaml new file mode 100644 index 0000000..f3cbe6c --- /dev/null +++ b/.github/workflows/version.yaml @@ -0,0 +1,76 @@ +name: Bump version + +# Autobump for the Flutter SDK, mirroring the Android/iOS pipeline but self-contained +# (no shared rees46/workflow changes). semantic-release computes the next version from +# Conventional Commits; this workflow writes it into pubspec.yaml + CHANGELOG.md and +# commits `chore(release): ` to master. The existing deploy.yaml then tags +# v, which triggers publish.yaml -> pub.dev (OIDC). +on: + push: + branches: + - master + paths-ignore: + - .github + workflow_dispatch: + +jobs: + # Skip bumping when HEAD is itself a release commit — otherwise the push made by + # this workflow would trigger another bump (infinite loop). + find-release-commit: + uses: rees46/workflow/.github/workflows/reusable-release-commit-finder.yaml@master + with: + commitMessage: 'chore(release):' + secrets: + githubToken: ${{ secrets.GITHUB_TOKEN }} + + bump-version: + needs: find-release-commit + if: | + github.event_name == 'workflow_dispatch' || ( + github.event_name == 'push' && + needs.find-release-commit.outputs.hasCommit == 'false') + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.VERSIONER_ID }} + private-key: ${{ secrets.VERSIONER_SECRET }} + + - name: Checkout + uses: actions/checkout@v5 + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + + # Dry-run: compute the next version + notes from Conventional Commits without + # tagging, committing or publishing. Reads .releaserc.yml from the repo root. + - name: Compute next version + id: semantic + uses: docker://ghcr.io/codfish/semantic-release-action:v3 + with: + dry-run: true + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Apply version bump + if: steps.semantic.outputs.new-release-published == 'true' + env: + RELEASE_VERSION: ${{ steps.semantic.outputs.release-version }} + RELEASE_NOTES: ${{ steps.semantic.outputs.release-notes }} + run: | + chmod +x ./scripts/apply_release.sh + ./scripts/apply_release.sh + + # Commit the bump to master. The existing deploy.yaml reacts to this push, + # tags v (with its own app token), and publish.yaml publishes. + - name: Commit and push release + if: steps.semantic.outputs.new-release-published == 'true' + run: | + git config user.name "rees46-versioner[bot]" + git config user.email "rees46-versioner[bot]@users.noreply.github.com" + git add pubspec.yaml CHANGELOG.md + git commit -m "chore(release): ${{ steps.semantic.outputs.release-version }}" + git push origin HEAD:master diff --git a/.releaserc.yml b/.releaserc.yml new file mode 100644 index 0000000..9243317 --- /dev/null +++ b/.releaserc.yml @@ -0,0 +1,14 @@ +# Autobump config for the Flutter SDK. +# +# semantic-release runs in DRY-RUN mode from .github/workflows/version.yaml — it only +# computes the next version + release notes from Conventional Commits (feat: -> minor, +# fix: -> patch, BREAKING CHANGE -> major). It does NOT tag, commit or publish here. +# +# The workflow then writes the computed version into pubspec.yaml + CHANGELOG.md and +# commits `chore(release): ` to master. The existing deploy.yaml tags +# v, which triggers publish.yaml -> pub.dev (OIDC). See version.yaml. +branches: + - master +plugins: + - '@semantic-release/commit-analyzer' + - '@semantic-release/release-notes-generator' diff --git a/scripts/apply_release.sh b/scripts/apply_release.sh new file mode 100755 index 0000000..a1085e4 --- /dev/null +++ b/scripts/apply_release.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# +# Applies a release computed by semantic-release to the working tree: +# 1. bumps `version:` in pubspec.yaml (pub.dev semver, no build number) +# 2. prepends the release notes to CHANGELOG.md (pub.dev renders this section) +# +# Inputs via environment (set by .github/workflows/version.yaml): +# RELEASE_VERSION required, e.g. 0.0.4 +# RELEASE_NOTES optional, markdown produced by semantic-release +# +# Passing notes via the environment (not as a CLI argument) keeps multi-line +# markdown with quotes/backticks safe from shell word-splitting. +set -euo pipefail + +: "${RELEASE_VERSION:?RELEASE_VERSION is required}" + +# 1) Bump the pubspec version. Matches the top-level `version:` line only. +sed -i.bak -E "s/^version:.*/version: ${RELEASE_VERSION}/" pubspec.yaml +rm -f pubspec.yaml.bak + +# 2) Prepend release notes to the changelog. Fall back to a bare version header +# so pub.dev always finds an entry for the published version. +NOTES="${RELEASE_NOTES:-}" +if [ -z "${NOTES}" ]; then + NOTES="## ${RELEASE_VERSION}" +fi + +TMP="$(mktemp)" +printf '%s\n\n' "${NOTES}" > "${TMP}" +cat CHANGELOG.md >> "${TMP}" +mv "${TMP}" CHANGELOG.md + +echo "Applied release ${RELEASE_VERSION}: pubspec.yaml + CHANGELOG.md updated" From 6014a39667c554dad533d0c7836f5ee5d5418d3a Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 30 Jun 2026 15:54:04 +0400 Subject: [PATCH 02/12] feat: add catalog read methods (profile, product counters, category, collection) Mirror the native catalog read API in the Flutter SDK via Pigeon: - getProfile(), getProductCounters(item), getCategory(category, {limit, page}) and getCollection(collectionId), each returning a typed response model. - Regenerate pigeon (Dart/Kotlin/Swift); implement the Android (Gson) and iOS (JSONSerialization) bridges; add Dart response models, exports and tests. - Bump native deps to the releases that expose these managers: Android com.github.rees46:android-sdk v2.34.0, iOS REES46 3.29.0. --- android/build.gradle.kts | 6 +- .../Rees46FlutterSdkPlugin.kt | 82 +++ .../pigeon/PersonalizationApi.g.kt | 101 +++ example/pubspec.lock | 2 +- ios/Classes/Rees46FlutterSdkPlugin.swift | 161 +++++ ios/Classes/pigeon/PersonalizationApi.g.swift | 90 +++ ios/rees46_sdk.podspec | 2 +- lib/rees46_sdk.dart | 4 + lib/src/category/category_response.dart | 28 + lib/src/collection/collection_response.dart | 21 + lib/src/personalization_sdk.dart | 51 ++ lib/src/pigeon/personalization_api.g.dart | 593 +++++++++--------- .../products/product_counters_response.dart | 68 ++ lib/src/profile/profile_response.dart | 49 ++ pigeons/personalization_api.dart | 21 + test/catalog_test.dart | 184 ++++++ 16 files changed, 1151 insertions(+), 312 deletions(-) create mode 100644 lib/src/category/category_response.dart create mode 100644 lib/src/collection/collection_response.dart create mode 100644 lib/src/products/product_counters_response.dart create mode 100644 lib/src/profile/profile_response.dart create mode 100644 test/catalog_test.dart diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 8aff7c9..0888b3d 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -84,9 +84,9 @@ dependencies { // REES46 Android SDK (JitPack). // // Published from github.com/rees46/android-sdk as `com.github.rees46:android-sdk:`. - // v2.33.0 is the first release that exposes the loyalty manager - // (`SDK.instance.loyaltyManager`). - val rees46AndroidSdkVersion = "v2.33.0" + // v2.34.0 adds the catalog read managers (profile, product counters, + // category, collection) on top of the loyalty manager (v2.33.0). + val rees46AndroidSdkVersion = "v2.34.0" add( "rees46Implementation", "com.github.rees46:android-sdk:$rees46AndroidSdkVersion", diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt index 30c52f6..d46a5a4 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt @@ -354,6 +354,88 @@ class Rees46FlutterSdkPlugin : } } + override fun getProfile(callback: (Result) -> Unit) { + try { + SDK.instance.profileManager.getProfile( + onSuccess = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("get_profile_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("get_profile_failed", t.message, null))) + } + } + + override fun getProductCounters(item: String, callback: (Result) -> Unit) { + if (item.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "item is required", null))) + return + } + try { + SDK.instance.productsManager.getProductCounters( + item = item, + onSuccess = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("product_counters_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("product_counters_failed", t.message, null))) + } + } + + override fun getCategory( + category: String, + limit: Long?, + page: Long?, + callback: (Result) -> Unit, + ) { + if (category.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "category is required", null))) + return + } + try { + SDK.instance.categoryManager.getCategory( + category = category, + limit = limit?.toInt(), + page = page?.toInt(), + onSuccess = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("get_category_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("get_category_failed", t.message, null))) + } + } + + override fun getCollection(collectionId: String, callback: (Result) -> Unit) { + if (collectionId.isBlank()) { + callback(Result.failure(FlutterError("bad_args", "collectionId is required", null))) + return + } + try { + SDK.instance.collectionManager.getCollection( + collectionId = collectionId, + onSuccess = { response -> + callback(Result.success(Gson().toJson(response))) + }, + onError = { code, message -> + callback(Result.failure(FlutterError("get_collection_failed", message ?: "error $code", null))) + }, + ) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("get_collection_failed", t.message, null))) + } + } + override fun getSid(): String = SDK.instance.getSid() override fun getDid(): String? = SDK.instance.getDid() diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt index ad35a75..e485e0f 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt @@ -372,6 +372,27 @@ interface PersonalizationHostApi { * Dart layer parses the result into [LoyaltyStatusResponse]. */ fun getLoyaltyStatus(identifier: String, callback: (Result) -> Unit) + /** + * Returns the current user's profile as a JSON string. + * Dart layer parses the result into [ProfileResponse]. + */ + fun getProfile(callback: (Result) -> Unit) + /** + * Returns view / cart / purchase counters for [item] as a JSON string. + * Dart layer parses the result into [ProductCountersResponse]. + */ + fun getProductCounters(item: String, callback: (Result) -> Unit) + /** + * Returns a category product listing as a JSON string. + * [limit] and [page] paginate the result; both are optional. + * Dart layer parses the result into [CategoryResponse]. + */ + fun getCategory(category: String, limit: Long?, page: Long?, callback: (Result) -> Unit) + /** + * Returns a merchandised collection's products as a JSON string. + * Dart layer parses the result into [CollectionResponse]. + */ + fun getCollection(collectionId: 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) @@ -670,6 +691,86 @@ interface PersonalizationHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getProfile{ 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.getProductCounters$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val itemArg = args[0] as String + api.getProductCounters(itemArg) { 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.getCategory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val categoryArg = args[0] as String + val limitArg = args[1] as Long? + val pageArg = args[2] as Long? + api.getCategory(categoryArg, limitArg, pageArg) { 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.getCollection$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val collectionIdArg = args[0] as String + api.getCollection(collectionIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(PersonalizationApiPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/example/pubspec.lock b/example/pubspec.lock index e8bd460..6906544 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -256,7 +256,7 @@ packages: path: ".." relative: true source: path - version: "0.0.1" + version: "0.0.3" shelf: dependency: transitive description: diff --git a/ios/Classes/Rees46FlutterSdkPlugin.swift b/ios/Classes/Rees46FlutterSdkPlugin.swift index 576ed58..8cfcbbd 100644 --- a/ios/Classes/Rees46FlutterSdkPlugin.swift +++ b/ios/Classes/Rees46FlutterSdkPlugin.swift @@ -573,6 +573,167 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } + func getProfile(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.getProfile { result in + switch result { + case .success(let response): + Self._encodeCatalog(Self._profileResponseToDict(response), errorCode: "get_profile_failed", completion: completion) + case .failure(let err): + completion(.failure(PigeonError(code: "get_profile_failed", message: String(describing: err), details: nil))) + } + } + } + + func getProductCounters(item: 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 item.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "item is required", details: nil))) + return + } + sdk.getProductCounters(item: item) { result in + switch result { + case .success(let response): + Self._encodeCatalog(Self._productCountersToDict(response), errorCode: "product_counters_failed", completion: completion) + case .failure(let err): + completion(.failure(PigeonError(code: "product_counters_failed", message: String(describing: err), details: nil))) + } + } + } + + func getCategory(category: String, limit: Int64?, page: Int64?, 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 category.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "category is required", details: nil))) + return + } + sdk.getCategory( + category: category, + limit: limit.map { Int($0) }, + page: page.map { Int($0) }, + brands: nil, + locations: nil, + filters: nil + ) { result in + switch result { + case .success(let response): + Self._encodeCatalog(Self._categoryResponseToDict(response), errorCode: "get_category_failed", completion: completion) + case .failure(let err): + completion(.failure(PigeonError(code: "get_category_failed", message: String(describing: err), details: nil))) + } + } + } + + func getCollection(collectionId: 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 collectionId.isEmpty { + completion(.failure(PigeonError(code: "bad_args", message: "collectionId is required", details: nil))) + return + } + sdk.getCollection( + collectionId: collectionId, + location: nil, + email: nil, + phone: nil, + externalId: nil, + loyaltyId: nil + ) { result in + switch result { + case .success(let response): + Self._encodeCatalog(Self._collectionResponseToDict(response), errorCode: "get_collection_failed", completion: completion) + case .failure(let err): + completion(.failure(PigeonError(code: "get_collection_failed", message: String(describing: err), details: nil))) + } + } + } + + private static func _profileResponseToDict(_ p: GetProfileResponse) -> [String: Any] { + var d: [String: Any] = [:] + if let v = p.id { d["id"] = v } + if let v = p.email { d["email"] = v } + if let v = p.phone { d["phone"] = v } + if let v = p.firstName { d["first_name"] = v } + if let v = p.lastName { d["last_name"] = v } + if let v = p.hasEmail { d["has_email"] = v } + if let v = p.emailRegisteredAt { d["email_registered_at"] = v } + if let v = p.gender { d["gender"] = v } + if let v = p.computedGender { d["computed_gender"] = v } + if let v = p.boughtSomething { d["bought_something"] = v } + if JSONSerialization.isValidJSONObject(p.customProperties) { + d["custom_properties"] = p.customProperties + } + return d + } + + private static func _productCountersToDict(_ r: ProductCountersResponse) -> [String: Any] { + func counterDict(_ c: ProductCounter?) -> [String: Any]? { + guard let c = c else { return nil } + return ["view": c.view, "cart": c.cart, "purchase": c.purchase] + } + var d: [String: Any] = [:] + if let v = counterDict(r.daily) { d["daily"] = v } + if let v = counterDict(r.now) { d["now"] = v } + if let t = r.triggers { + d["triggers"] = ["back_in_stock": t.backInStock, "price_drop": t.priceDrop] + } + return d + } + + private static func _categoryResponseToDict(_ r: CategoryResponse) -> [String: Any] { + return [ + "products_total": r.productsTotal, + "products": r.products.map { _categoryProductToDict($0) }, + ] + } + + private static func _collectionResponseToDict(_ r: CollectionResponse) -> [String: Any] { + return ["products": r.products.map { _categoryProductToDict($0) }] + } + + private static func _categoryProductToDict(_ p: CategoryProduct) -> [String: Any] { + var d: [String: Any] = [:] + if let v = p.id { d["id"] = v } + if let v = p.name { d["name"] = v } + if let v = p.brand { d["brand"] = v } + if let v = p.price { d["price"] = v } + if let v = p.priceFormatted { d["price_formatted"] = v } + if let v = p.priceFull { d["price_full"] = v } + if let v = p.priceFullFormatted { d["price_full_formatted"] = v } + if let v = p.currency { d["currency"] = v } + if let v = p.imageUrl { d["image_url"] = v } + if let v = p.url { d["url"] = v } + if let v = p.picture { d["picture"] = v } + return d + } + + /// Serializes a catalog response dictionary to a JSON string, mirroring the + /// other JSON-returning host methods. + private static func _encodeCatalog( + _ dict: [String: Any], + errorCode: String, + completion: @escaping (Result) -> Void + ) { + guard JSONSerialization.isValidJSONObject(dict), + let data = try? JSONSerialization.data(withJSONObject: dict), + let json = String(data: data, encoding: .utf8) else { + completion(.failure(PigeonError(code: "serialization_failed", message: "Failed to serialize \(errorCode) response", details: nil))) + return + } + completion(.success(json)) + } + /// Serializes a loyalty response envelope `{ "status": ..., "payload": ... }` /// to a JSON string, mirroring the other JSON-returning host methods. private static func _encodeEnvelope( diff --git a/ios/Classes/pigeon/PersonalizationApi.g.swift b/ios/Classes/pigeon/PersonalizationApi.g.swift index 570c819..c5119e8 100644 --- a/ios/Classes/pigeon/PersonalizationApi.g.swift +++ b/ios/Classes/pigeon/PersonalizationApi.g.swift @@ -431,6 +431,19 @@ protocol PersonalizationHostApi { /// [identifier] is the member identifier (phone). /// Dart layer parses the result into [LoyaltyStatusResponse]. func getLoyaltyStatus(identifier: String, completion: @escaping (Result) -> Void) + /// Returns the current user's profile as a JSON string. + /// Dart layer parses the result into [ProfileResponse]. + func getProfile(completion: @escaping (Result) -> Void) + /// Returns view / cart / purchase counters for [item] as a JSON string. + /// Dart layer parses the result into [ProductCountersResponse]. + func getProductCounters(item: String, completion: @escaping (Result) -> Void) + /// Returns a category product listing as a JSON string. + /// [limit] and [page] paginate the result; both are optional. + /// Dart layer parses the result into [CategoryResponse]. + func getCategory(category: String, limit: Int64?, page: Int64?, completion: @escaping (Result) -> Void) + /// Returns a merchandised collection's products as a JSON string. + /// Dart layer parses the result into [CollectionResponse]. + func getCollection(collectionId: 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) } @@ -718,6 +731,83 @@ class PersonalizationHostApiSetup { } else { getLoyaltyStatusChannel.setMessageHandler(nil) } + /// Returns the current user's profile as a JSON string. + /// Dart layer parses the result into [ProfileResponse]. + let getProfileChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getProfileChannel.setMessageHandler { _, reply in + api.getProfile { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getProfileChannel.setMessageHandler(nil) + } + /// Returns view / cart / purchase counters for [item] as a JSON string. + /// Dart layer parses the result into [ProductCountersResponse]. + let getProductCountersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductCounters\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getProductCountersChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let itemArg = args[0] as! String + api.getProductCounters(item: itemArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getProductCountersChannel.setMessageHandler(nil) + } + /// Returns a category product listing as a JSON string. + /// [limit] and [page] paginate the result; both are optional. + /// Dart layer parses the result into [CategoryResponse]. + let getCategoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCategory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getCategoryChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let categoryArg = args[0] as! String + let limitArg: Int64? = nilOrValue(args[1]) + let pageArg: Int64? = nilOrValue(args[2]) + api.getCategory(category: categoryArg, limit: limitArg, page: pageArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getCategoryChannel.setMessageHandler(nil) + } + /// Returns a merchandised collection's products as a JSON string. + /// Dart layer parses the result into [CollectionResponse]. + let getCollectionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCollection\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getCollectionChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let collectionIdArg = args[0] as! String + api.getCollection(collectionId: collectionIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getCollectionChannel.setMessageHandler(nil) + } /// [customJson] and [recommendedSourceJson] are JSON object strings or null. let trackPurchaseChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { diff --git a/ios/rees46_sdk.podspec b/ios/rees46_sdk.podspec index 9383768..93e15ba 100644 --- a/ios/rees46_sdk.podspec +++ b/ios/rees46_sdk.podspec @@ -14,7 +14,7 @@ Flutter plugin wrapper around REES46 native SDK. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.dependency 'REES46', '3.28.0' + s.dependency 'REES46', '3.29.0' s.platform = :ios, '13.0' # Flutter.framework does not contain a i386 slice. diff --git a/lib/rees46_sdk.dart b/lib/rees46_sdk.dart index 5694828..ef48577 100644 --- a/lib/rees46_sdk.dart +++ b/lib/rees46_sdk.dart @@ -1,6 +1,10 @@ export 'src/personalization_sdk.dart'; export 'src/loyalty/loyalty_response.dart'; +export 'src/category/category_response.dart'; +export 'src/collection/collection_response.dart'; +export 'src/products/product_counters_response.dart'; export 'src/profile/profile_params.dart'; +export 'src/profile/profile_response.dart'; export 'src/recommendation/recommendation_params.dart'; export 'src/recommendation/recommendation_response.dart'; export 'src/products/products_list_params.dart'; diff --git a/lib/src/category/category_response.dart b/lib/src/category/category_response.dart new file mode 100644 index 0000000..257f10c --- /dev/null +++ b/lib/src/category/category_response.dart @@ -0,0 +1,28 @@ +import '../products/products_list_response.dart' show Product; + +export '../products/products_list_response.dart' show Product; + +/// Response from [PersonalizationSdk.getCategory]. +/// +/// Exposes the common subset available on both platforms: the total product +/// count and the product list. Products are parsed with the shared [Product] +/// model (Android returns a richer product than iOS; the parser tolerates the +/// missing fields). +class CategoryResponse { + final int productsTotal; + final List products; + + const CategoryResponse({ + required this.productsTotal, + required this.products, + }); + + factory CategoryResponse.fromJson(Map json) { + return CategoryResponse( + productsTotal: json['products_total'] as int? ?? 0, + products: (json['products'] as List? ?? []) + .map((e) => Product.fromJson(e as Map)) + .toList(), + ); + } +} diff --git a/lib/src/collection/collection_response.dart b/lib/src/collection/collection_response.dart new file mode 100644 index 0000000..0eda946 --- /dev/null +++ b/lib/src/collection/collection_response.dart @@ -0,0 +1,21 @@ +import '../products/products_list_response.dart' show Product; + +export '../products/products_list_response.dart' show Product; + +/// Response from [PersonalizationSdk.getCollection]. +/// +/// A merchandised collection is just a product list; products are parsed with +/// the shared [Product] model. +class CollectionResponse { + final List products; + + const CollectionResponse({required this.products}); + + factory CollectionResponse.fromJson(Map json) { + return CollectionResponse( + products: (json['products'] as List? ?? []) + .map((e) => Product.fromJson(e as Map)) + .toList(), + ); + } +} diff --git a/lib/src/personalization_sdk.dart b/lib/src/personalization_sdk.dart index 8bb5071..eb2107d 100644 --- a/lib/src/personalization_sdk.dart +++ b/lib/src/personalization_sdk.dart @@ -1,9 +1,13 @@ import 'dart:convert'; import 'pigeon/personalization_api.g.dart' as pigeon; +import 'category/category_response.dart'; +import 'collection/collection_response.dart'; import 'init/sdk_init_handler.dart'; import 'loyalty/loyalty_response.dart'; +import 'products/product_counters_response.dart'; import 'profile/profile_params.dart'; +import 'profile/profile_response.dart'; import 'push/push_notification_callbacks.dart'; import 'recommendation/recommendation_params.dart'; import 'recommendation/recommendation_response.dart'; @@ -144,6 +148,53 @@ class PersonalizationSdk { ); } + /// Returns the current user's profile (native `ProfileManager.getProfile`). + Future getProfile() async { + final json = await _api.getProfile(); + return ProfileResponse.fromJson(jsonDecode(json) as Map); + } + + /// Returns view / cart / purchase counters for [item] + /// (native `ProductsManager.getProductCounters`). + Future getProductCounters(String item) async { + if (item.isEmpty) { + throw ArgumentError.value(item, 'item', 'must be non-empty'); + } + final json = await _api.getProductCounters(item); + return ProductCountersResponse.fromJson( + jsonDecode(json) as Map, + ); + } + + /// Returns a category product listing (native `CategoryManager.getCategory`). + /// + /// [limit] and [page] paginate the result; both are optional. + Future getCategory( + String category, { + int? limit, + int? page, + }) async { + if (category.isEmpty) { + throw ArgumentError.value(category, 'category', 'must be non-empty'); + } + final json = await _api.getCategory(category, limit, page); + return CategoryResponse.fromJson(jsonDecode(json) as Map); + } + + /// Returns a merchandised collection's products + /// (native `CollectionManager.getCollection`). + Future getCollection(String collectionId) async { + if (collectionId.isEmpty) { + throw ArgumentError.value( + collectionId, + 'collectionId', + 'must be non-empty', + ); + } + final json = await _api.getCollection(collectionId); + return CollectionResponse.fromJson(jsonDecode(json) as Map); + } + Future getRecommendation( String code, { RecommendationParams? params, diff --git a/lib/src/pigeon/personalization_api.g.dart b/lib/src/pigeon/personalization_api.g.dart index c8ad2cd..5a90972 100644 --- a/lib/src/pigeon/personalization_api.g.dart +++ b/lib/src/pigeon/personalization_api.g.dart @@ -15,11 +15,7 @@ PlatformException _createConnectionError(String channelName) { ); } -List wrapResponse({ - Object? result, - PlatformException? error, - bool empty = false, -}) { +List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { if (empty) { return []; } @@ -28,25 +24,21 @@ List wrapResponse({ } 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]), - ); + 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.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, @@ -89,8 +81,7 @@ class InitConfig { } Object encode() { - return _toList(); - } + return _toList(); } static InitConfig decode(Object result) { result as List; @@ -120,7 +111,8 @@ class InitConfig { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } /// Wire format for one purchase line (maps to native `PurchaseItemRequest`). @@ -147,12 +139,17 @@ class PurchaseLineItemWire { String? fashionSize; List _toList() { - return [id, amount, price, lineId, fashionSize]; + return [ + id, + amount, + price, + lineId, + fashionSize, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static PurchaseLineItemWire decode(Object result) { result as List; @@ -179,7 +176,8 @@ class PurchaseLineItemWire { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } /// Wire format for profile fields sent to native SDK. @@ -277,8 +275,7 @@ class ProfileParamsWire { } Object encode() { - return _toList(); - } + return _toList(); } static ProfileParamsWire decode(Object result) { result as List; @@ -320,9 +317,11 @@ class ProfileParamsWire { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -330,13 +329,13 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is InitConfig) { + } else if (value is InitConfig) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is PurchaseLineItemWire) { + } else if (value is PurchaseLineItemWire) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is ProfileParamsWire) { + } else if (value is ProfileParamsWire) { buffer.putUint8(131); writeValue(buffer, value.encode()); } else { @@ -347,11 +346,11 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return InitConfig.decode(readValue(buffer)!); - case 130: + case 130: return PurchaseLineItemWire.decode(readValue(buffer)!); - case 131: + case 131: return ProfileParamsWire.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -363,13 +362,9 @@ 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' - : ''; + PersonalizationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -377,17 +372,13 @@ class PersonalizationHostApi { 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 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) { @@ -404,14 +395,12 @@ class PersonalizationHostApi { } 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 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?; @@ -435,14 +424,12 @@ class PersonalizationHostApi { /// 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 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?; @@ -460,25 +447,14 @@ class PersonalizationHostApi { } /// [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], + 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) { @@ -495,17 +471,13 @@ class PersonalizationHostApi { } 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 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) { @@ -525,17 +497,13 @@ class PersonalizationHostApi { /// [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 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) { @@ -558,14 +526,12 @@ class PersonalizationHostApi { /// 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 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?; @@ -589,14 +555,12 @@ class PersonalizationHostApi { /// 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 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?; @@ -616,17 +580,13 @@ class PersonalizationHostApi { /// 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 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) { @@ -651,17 +611,13 @@ class PersonalizationHostApi { /// [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 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) { @@ -686,14 +642,12 @@ class PersonalizationHostApi { /// 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 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?; @@ -719,17 +673,13 @@ class PersonalizationHostApi { /// [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 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) { @@ -754,17 +704,13 @@ class PersonalizationHostApi { /// [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 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) { @@ -789,23 +735,14 @@ class PersonalizationHostApi { /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. /// Dart layer parses the result into [LoyaltyJoinResponse]. - Future joinLoyalty( - String phone, - String? email, - String? firstName, - String? lastName, - ) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [phone, email, firstName, lastName], + Future joinLoyalty(String phone, String? email, String? firstName, String? lastName) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([phone, email, firstName, lastName]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -831,17 +768,134 @@ class PersonalizationHostApi { /// [identifier] is the member identifier (phone). /// Dart layer parses the result into [LoyaltyStatusResponse]. Future getLoyaltyStatus(String identifier) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [identifier], + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([identifier]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as String?)!; + } + } + + /// Returns the current user's profile as a JSON string. + /// Dart layer parses the result into [ProfileResponse]. + Future getProfile() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile$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 view / cart / purchase counters for [item] as a JSON string. + /// Dart layer parses the result into [ProductCountersResponse]. + Future getProductCounters(String item) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductCounters$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([item]); + 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 category product listing as a JSON string. + /// [limit] and [page] paginate the result; both are optional. + /// Dart layer parses the result into [CategoryResponse]. + Future getCategory(String category, int? limit, int? page) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCategory$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([category, limit, page]); + 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 merchandised collection's products as a JSON string. + /// Dart layer parses the result into [CollectionResponse]. + Future getCollection(String collectionId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCollection$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([collectionId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -863,53 +917,14 @@ class PersonalizationHostApi { } /// [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, - ]); + 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) { @@ -935,115 +950,79 @@ abstract class PersonalizationFlutterApi { void onPushClicked(Map payload); - static void setUp( - PersonalizationFlutterApi? api, { - BinaryMessenger? binaryMessenger, - String messageChannelSuffix = '', - }) { - messageChannelSuffix = messageChannelSuffix.isNotEmpty - ? '.$messageChannelSuffix' - : ''; + 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, - ); + 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.', - ); + 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.', - ); + 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()), - ); + } 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, - ); + 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.', - ); + 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.', - ); + 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()), - ); + } 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, - ); + 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.', - ); + 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.', - ); + 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()), - ); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); } diff --git a/lib/src/products/product_counters_response.dart b/lib/src/products/product_counters_response.dart new file mode 100644 index 0000000..53ddcce --- /dev/null +++ b/lib/src/products/product_counters_response.dart @@ -0,0 +1,68 @@ +/// Response from [PersonalizationSdk.getProductCounters]. +/// +/// Mirrors native `ProductCountersResponse`: per-period view/cart/purchase +/// counters plus trigger flags. +class ProductCountersResponse { + final ProductCounter? daily; + final ProductCounter? now; + final ProductCounterTriggers? triggers; + + const ProductCountersResponse({this.daily, this.now, this.triggers}); + + factory ProductCountersResponse.fromJson(Map json) { + ProductCounter? counter(String key) { + final raw = json[key]; + return raw is Map + ? ProductCounter.fromJson(raw.cast()) + : null; + } + + final triggers = json['triggers']; + return ProductCountersResponse( + daily: counter('daily'), + now: counter('now'), + triggers: triggers is Map + ? ProductCounterTriggers.fromJson(triggers.cast()) + : null, + ); + } +} + +/// View / cart / purchase counters for a single period. +class ProductCounter { + final int view; + final int cart; + final int purchase; + + const ProductCounter({ + required this.view, + required this.cart, + required this.purchase, + }); + + factory ProductCounter.fromJson(Map json) { + return ProductCounter( + view: json['view'] as int? ?? 0, + cart: json['cart'] as int? ?? 0, + purchase: json['purchase'] as int? ?? 0, + ); + } +} + +/// Trigger counters returned inside `ProductCountersResponse.triggers`. +class ProductCounterTriggers { + final int backInStock; + final int priceDrop; + + const ProductCounterTriggers({ + required this.backInStock, + required this.priceDrop, + }); + + factory ProductCounterTriggers.fromJson(Map json) { + return ProductCounterTriggers( + backInStock: json['back_in_stock'] as int? ?? 0, + priceDrop: json['price_drop'] as int? ?? 0, + ); + } +} diff --git a/lib/src/profile/profile_response.dart b/lib/src/profile/profile_response.dart new file mode 100644 index 0000000..3263104 --- /dev/null +++ b/lib/src/profile/profile_response.dart @@ -0,0 +1,49 @@ +/// Response from [PersonalizationSdk.getProfile]. +/// +/// Mirrors native `GetProfileResponse`. All scalar fields are optional; +/// [customProperties] is kept as a raw map because its shape is shop-defined. +class ProfileResponse { + final String? id; + final String? email; + final String? phone; + final String? firstName; + final String? lastName; + final bool? hasEmail; + final String? emailRegisteredAt; + final String? gender; + final String? computedGender; + final bool? boughtSomething; + final Map customProperties; + + const ProfileResponse({ + this.id, + this.email, + this.phone, + this.firstName, + this.lastName, + this.hasEmail, + this.emailRegisteredAt, + this.gender, + this.computedGender, + this.boughtSomething, + this.customProperties = const {}, + }); + + factory ProfileResponse.fromJson(Map json) { + return ProfileResponse( + id: json['id'] as String?, + email: json['email'] as String?, + phone: json['phone'] as String?, + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + hasEmail: json['has_email'] as bool?, + emailRegisteredAt: json['email_registered_at'] as String?, + gender: json['gender'] as String?, + computedGender: json['computed_gender'] as String?, + boughtSomething: json['bought_something'] as bool?, + customProperties: + (json['custom_properties'] as Map?)?.cast() ?? + const {}, + ); + } +} diff --git a/pigeons/personalization_api.dart b/pigeons/personalization_api.dart index 4ffd05b..7fe95aa 100644 --- a/pigeons/personalization_api.dart +++ b/pigeons/personalization_api.dart @@ -193,6 +193,27 @@ abstract class PersonalizationHostApi { @async String getLoyaltyStatus(String identifier); + /// Returns the current user's profile as a JSON string. + /// Dart layer parses the result into [ProfileResponse]. + @async + String getProfile(); + + /// Returns view / cart / purchase counters for [item] as a JSON string. + /// Dart layer parses the result into [ProductCountersResponse]. + @async + String getProductCounters(String item); + + /// Returns a category product listing as a JSON string. + /// [limit] and [page] paginate the result; both are optional. + /// Dart layer parses the result into [CategoryResponse]. + @async + String getCategory(String category, int? limit, int? page); + + /// Returns a merchandised collection's products as a JSON string. + /// Dart layer parses the result into [CollectionResponse]. + @async + String getCollection(String collectionId); + /// [customJson] and [recommendedSourceJson] are JSON object strings or null. @async void trackPurchase( diff --git a/test/catalog_test.dart b/test/catalog_test.dart new file mode 100644 index 0000000..53f7693 --- /dev/null +++ b/test/catalog_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rees46_sdk/rees46_sdk.dart'; +import 'package:rees46_sdk/src/pigeon/personalization_api.g.dart' as pigeon; + +const _profileChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile'; +const _countersChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductCounters'; +const _categoryChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCategory'; +const _collectionChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCollection'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final messageCodec = pigeon.PersonalizationHostApi.pigeonChannelCodec; + ByteData reply(String value) => messageCodec.encodeMessage([value])!; + + void stub(String channel, String json) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (_) async => reply(json)); + } + + tearDown(() { + for (final c in [ + _profileChannel, + _countersChannel, + _categoryChannel, + _collectionChannel, + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(c, null); + } + }); + + // ------------------------------------------------------------------------- + // getProfile + // ------------------------------------------------------------------------- + group('getProfile', () { + test('parses scalar fields and custom properties', () async { + stub( + _profileChannel, + jsonEncode({ + 'id': 'u1', + 'email': 'a@b.c', + 'has_email': true, + 'gender': 'm', + 'bought_something': false, + 'custom_properties': {'tier': 'gold'}, + }), + ); + + final r = await PersonalizationSdk().getProfile(); + expect(r, isA()); + expect(r.id, equals('u1')); + expect(r.email, equals('a@b.c')); + expect(r.hasEmail, isTrue); + expect(r.gender, equals('m')); + expect(r.boughtSomething, isFalse); + expect(r.customProperties['tier'], equals('gold')); + }); + + test('missing fields parse to nulls / empty map', () async { + stub(_profileChannel, jsonEncode({'id': 'u1'})); + + final r = await PersonalizationSdk().getProfile(); + expect(r.id, equals('u1')); + expect(r.email, isNull); + expect(r.hasEmail, isNull); + expect(r.customProperties, isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // getProductCounters + // ------------------------------------------------------------------------- + group('getProductCounters', () { + test('parses periods and triggers', () async { + stub( + _countersChannel, + jsonEncode({ + 'now': {'view': 3, 'cart': 1, 'purchase': 0}, + 'daily': {'view': 10, 'cart': 4, 'purchase': 2}, + 'triggers': {'back_in_stock': 1, 'price_drop': 5}, + }), + ); + + final r = await PersonalizationSdk().getProductCounters('item1'); + expect(r, isA()); + expect(r.now?.view, equals(3)); + expect(r.daily?.purchase, equals(2)); + expect(r.triggers?.priceDrop, equals(5)); + expect(r.triggers?.backInStock, equals(1)); + }); + + test('absent sections parse to null', () async { + stub(_countersChannel, jsonEncode({'now': {'view': 1, 'cart': 0, 'purchase': 0}})); + + final r = await PersonalizationSdk().getProductCounters('item1'); + expect(r.now?.view, equals(1)); + expect(r.daily, isNull); + expect(r.triggers, isNull); + }); + + test('empty item throws ArgumentError (no native call)', () async { + expect( + () => PersonalizationSdk().getProductCounters(''), + throwsA(isA()), + ); + }); + }); + + // ------------------------------------------------------------------------- + // getCategory + // ------------------------------------------------------------------------- + group('getCategory', () { + test('parses total and products', () async { + stub( + _categoryChannel, + jsonEncode({ + 'products_total': 2, + 'products': [ + {'id': 'p1', 'name': 'One', 'price': 9.9, 'currency': 'USD'}, + {'id': 'p2', 'name': 'Two', 'price': 19.9, 'currency': 'USD'}, + ], + }), + ); + + final r = await PersonalizationSdk().getCategory('shoes', limit: 5); + expect(r, isA()); + expect(r.productsTotal, equals(2)); + expect(r.products, hasLength(2)); + expect(r.products.first.id, equals('p1')); + expect(r.products.first.price, equals(9.9)); + }); + + test('empty products defaults to empty list', () async { + stub(_categoryChannel, jsonEncode({'products_total': 0})); + + final r = await PersonalizationSdk().getCategory('shoes'); + expect(r.productsTotal, equals(0)); + expect(r.products, isEmpty); + }); + + test('empty category throws ArgumentError (no native call)', () async { + expect( + () => PersonalizationSdk().getCategory(''), + throwsA(isA()), + ); + }); + }); + + // ------------------------------------------------------------------------- + // getCollection + // ------------------------------------------------------------------------- + group('getCollection', () { + test('parses products', () async { + stub( + _collectionChannel, + jsonEncode({ + 'products': [ + {'id': 'p1', 'name': 'One', 'price': 5.0, 'currency': 'EUR'}, + ], + }), + ); + + final r = await PersonalizationSdk().getCollection('1'); + expect(r, isA()); + expect(r.products, hasLength(1)); + expect(r.products.first.id, equals('p1')); + }); + + test('empty collectionId throws ArgumentError (no native call)', () async { + expect( + () => PersonalizationSdk().getCollection(''), + throwsA(isA()), + ); + }); + }); +} From a52c3f1a193c3775204a114cb127434cc9446c8a Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Wed, 1 Jul 2026 23:16:33 +0400 Subject: [PATCH 03/12] fix: tolerate string-encoded numbers in API response models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REES46 API is inconsistent about numeric encoding — e.g. a product's `price` comes back as the string "14990.0" from products/get while `price_full` is a JSON number. `json['price'] as num?` threw 'String is not a subtype of type num?', so getProductInfo, search, getRecommendation, getCategory and getCollection all failed to parse. Add toDoubleOrNull/toIntOrNull helpers that accept num, String or null and use them for every numeric field in the response models. Coerce `id` via toString so numeric product ids don't break the String cast either. --- lib/src/category/category_response.dart | 3 +- lib/src/json_number.dart | 29 +++++++++++++++++++ .../products/product_counters_response.dart | 11 +++---- lib/src/products/products_list_response.dart | 17 ++++++----- .../recommendation_response.dart | 13 +++++---- lib/src/search/search_response.dart | 25 ++++++++-------- 6 files changed, 66 insertions(+), 32 deletions(-) create mode 100644 lib/src/json_number.dart diff --git a/lib/src/category/category_response.dart b/lib/src/category/category_response.dart index 257f10c..d80404f 100644 --- a/lib/src/category/category_response.dart +++ b/lib/src/category/category_response.dart @@ -1,4 +1,5 @@ import '../products/products_list_response.dart' show Product; +import '../json_number.dart'; export '../products/products_list_response.dart' show Product; @@ -19,7 +20,7 @@ class CategoryResponse { factory CategoryResponse.fromJson(Map json) { return CategoryResponse( - productsTotal: json['products_total'] as int? ?? 0, + productsTotal: toIntOrNull(json['products_total']) ?? 0, products: (json['products'] as List? ?? []) .map((e) => Product.fromJson(e as Map)) .toList(), diff --git a/lib/src/json_number.dart b/lib/src/json_number.dart new file mode 100644 index 0000000..1030aa5 --- /dev/null +++ b/lib/src/json_number.dart @@ -0,0 +1,29 @@ +/// Tolerant numeric coercion for REES46 API responses. +/// +/// The API is not consistent about how it encodes numbers: some numeric fields +/// arrive as JSON numbers, others as strings — e.g. a product's `price` comes +/// back as `"14990.0"` (a string) while `price_full` is `14990.0` (a number). +/// A plain `value as num?` cast throws `'String' is not a subtype of type +/// 'num?'` on the string form, which previously made product/search/ +/// recommendation/catalog responses fail to parse. These helpers accept `num`, +/// `String`, or `null`. +library; + +/// Coerces [value] to a `double`, or returns `null` if it can't be parsed. +double? toDoubleOrNull(dynamic value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value.trim()); + return null; +} + +/// Coerces [value] to an `int`, or returns `null` if it can't be parsed. +/// +/// Accepts integer strings as well as numeric strings like `"6631.0"`. +int? toIntOrNull(dynamic value) { + if (value is num) return value.toInt(); + if (value is String) { + final s = value.trim(); + return int.tryParse(s) ?? double.tryParse(s)?.toInt(); + } + return null; +} diff --git a/lib/src/products/product_counters_response.dart b/lib/src/products/product_counters_response.dart index 53ddcce..f93a741 100644 --- a/lib/src/products/product_counters_response.dart +++ b/lib/src/products/product_counters_response.dart @@ -1,3 +1,4 @@ +import '../json_number.dart'; /// Response from [PersonalizationSdk.getProductCounters]. /// /// Mirrors native `ProductCountersResponse`: per-period view/cart/purchase @@ -42,9 +43,9 @@ class ProductCounter { factory ProductCounter.fromJson(Map json) { return ProductCounter( - view: json['view'] as int? ?? 0, - cart: json['cart'] as int? ?? 0, - purchase: json['purchase'] as int? ?? 0, + view: toIntOrNull(json['view']) ?? 0, + cart: toIntOrNull(json['cart']) ?? 0, + purchase: toIntOrNull(json['purchase']) ?? 0, ); } } @@ -61,8 +62,8 @@ class ProductCounterTriggers { factory ProductCounterTriggers.fromJson(Map json) { return ProductCounterTriggers( - backInStock: json['back_in_stock'] as int? ?? 0, - priceDrop: json['price_drop'] as int? ?? 0, + backInStock: toIntOrNull(json['back_in_stock']) ?? 0, + priceDrop: toIntOrNull(json['price_drop']) ?? 0, ); } } diff --git a/lib/src/products/products_list_response.dart b/lib/src/products/products_list_response.dart index 6c848ea..9fb1b2a 100644 --- a/lib/src/products/products_list_response.dart +++ b/lib/src/products/products_list_response.dart @@ -1,4 +1,5 @@ import '../recommendation/recommendation_response.dart' show ProductCategory; +import '../json_number.dart'; export '../recommendation/recommendation_response.dart' show ProductCategory; @@ -23,7 +24,7 @@ class ProductsListResponse { products: (json['products'] as List? ?? []) .map((e) => Product.fromJson(e as Map)) .toList(), - productsTotal: json['products_total'] as int? ?? 0, + productsTotal: toIntOrNull(json['products_total']) ?? 0, priceRange: json['price_range'] == null ? null : PriceRange.fromJson(json['price_range'] as Map), @@ -80,20 +81,20 @@ class Product { factory Product.fromJson(Map json) { return Product( - id: json['id'] as String? ?? '', + id: json['id']?.toString() ?? '', 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, + price: toDoubleOrNull(json['price']) ?? 0.0, + priceFull: toDoubleOrNull(json['price_full']) ?? 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, + salesRate: toIntOrNull(json['sales_rate']) ?? 0, relativeSalesRate: - (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, resizedImages: _parseResizedImages(json['image_url_resized']), categories: (json['categories'] as List? ?? []) .map((e) => ProductCategory.fromJson(e as Map)) @@ -122,8 +123,8 @@ class PriceRange { factory PriceRange.fromJson(Map json) { return PriceRange( - min: (json['min'] as num?)?.toDouble() ?? 0.0, - max: (json['max'] as num?)?.toDouble() ?? 0.0, + min: toDoubleOrNull(json['min']) ?? 0.0, + max: toDoubleOrNull(json['max']) ?? 0.0, ); } } diff --git a/lib/src/recommendation/recommendation_response.dart b/lib/src/recommendation/recommendation_response.dart index 95bce98..6398c58 100644 --- a/lib/src/recommendation/recommendation_response.dart +++ b/lib/src/recommendation/recommendation_response.dart @@ -1,3 +1,4 @@ +import '../json_number.dart'; /// Typed response for [PersonalizationSdk.getRecommendation]. /// /// Contains only fields present in **both** Android and iOS native SDK models. @@ -91,7 +92,7 @@ class RecommendedProduct { final rawCategories = json['categories'] as List? ?? []; final rawResized = json['image_url_resized']; return RecommendedProduct( - id: json['id'] as String? ?? '', + id: json['id']?.toString() ?? '', name: json['name'] as String? ?? '', brand: json['brand'] as String? ?? '', description: json['description'] as String? ?? '', @@ -104,14 +105,14 @@ class RecommendedProduct { 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, + price: toDoubleOrNull(json['price']) ?? 0.0, + priceFull: toDoubleOrNull(json['price_full']) ?? 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, + salesRate: toIntOrNull(json['sales_rate']) ?? 0, relativeSalesRate: - (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, ); } } @@ -138,7 +139,7 @@ class ProductCategory { factory ProductCategory.fromJson(Map json) { return ProductCategory( - id: json['id'] as String? ?? '', + id: json['id']?.toString() ?? '', name: json['name'] as String? ?? '', parentId: json['parent_id'] as String?, url: json['url'] as String?, diff --git a/lib/src/search/search_response.dart b/lib/src/search/search_response.dart index c8329d3..ee64d36 100644 --- a/lib/src/search/search_response.dart +++ b/lib/src/search/search_response.dart @@ -1,3 +1,4 @@ +import '../json_number.dart'; /// Response from [PersonalizationSdk.searchFull]. /// /// Excluded Android-only response fields: brands (`List`), clarification, @@ -26,7 +27,7 @@ class SearchFullResponse { categories: (json['categories'] as List? ?? []) .map((e) => SearchCategory.fromJson(e as Map)) .toList(), - productsTotal: json['products_total'] as int? ?? 0, + productsTotal: toIntOrNull(json['products_total']) ?? 0, priceRange: json['price_range'] == null ? null : SearchPriceRange.fromJson( @@ -84,20 +85,20 @@ class SearchProduct { factory SearchProduct.fromJson(Map json) { return SearchProduct( - id: json['id'] as String? ?? '', + id: json['id']?.toString() ?? '', 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, + price: toDoubleOrNull(json['price']) ?? 0.0, + priceFull: toDoubleOrNull(json['price_full']) ?? 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, + salesRate: toIntOrNull(json['sales_rate']) ?? 0, relativeSalesRate: - (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, resizedImages: _parseResizedImages(json['image_url_resized']), ); } @@ -133,7 +134,7 @@ class SearchInstantResponse { categories: (json['categories'] as List? ?? []) .map((e) => SearchCategory.fromJson(e as Map)) .toList(), - productsTotal: json['products_total'] as int? ?? 0, + productsTotal: toIntOrNull(json['products_total']) ?? 0, locations: (json['locations'] as List?) ?.map((e) => SearchLocation.fromJson(e as Map)) .toList(), @@ -176,11 +177,11 @@ class SearchCategory { factory SearchCategory.fromJson(Map json) { return SearchCategory( - id: json['id'] as String? ?? '', + id: json['id']?.toString() ?? '', name: json['name'] as String? ?? '', url: json['url'] as String?, parentId: json['parent'] as String?, - count: json['count'] as int?, + count: toIntOrNull(json['count']), ); } } @@ -193,8 +194,8 @@ class SearchPriceRange { factory SearchPriceRange.fromJson(Map json) { return SearchPriceRange( - min: (json['min'] as num?)?.toDouble() ?? 0.0, - max: (json['max'] as num?)?.toDouble() ?? 0.0, + min: toDoubleOrNull(json['min']) ?? 0.0, + max: toDoubleOrNull(json['max']) ?? 0.0, ); } } @@ -250,7 +251,7 @@ class SearchLocation { factory SearchLocation.fromJson(Map json) { return SearchLocation( - id: json['id'] as String? ?? '', + id: json['id']?.toString() ?? '', name: json['name'] as String? ?? '', type: json['type'] as String?, ); From 6040922493acff968292dba43e18083ab757148f Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Wed, 1 Jul 2026 23:17:13 +0400 Subject: [PATCH 04/12] fix: display incoming push notifications on Android Pushes were received but never shown: the plugin invoked the Pigeon FlutterApi (a @UiThread API) directly from the FCM background thread, which threw and aborted the message listener before anything was displayed. Marshal all FlutterApi calls onto the main dispatcher and post a heads-up BigPicture notification from a dedicated Rees46PushNotifier. Add a Rees46PushInitProvider so a cold-started process (app killed) still installs the notification channel and message listener before the first push arrives. --- android/build.gradle.kts | 4 + android/src/main/AndroidManifest.xml | 10 ++ .../Rees46FlutterSdkPlugin.kt | 42 +++--- .../push/Rees46PushInitProvider.kt | 64 +++++++++ .../push/Rees46PushNotifier.kt | 121 ++++++++++++++++++ 5 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt create mode 100644 android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 0888b3d..a922ab9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -92,6 +92,10 @@ dependencies { "com.github.rees46:android-sdk:$rees46AndroidSdkVersion", ) + // Used directly by the push presenter (NotificationCompat / ContextCompat). The native SDK + // depends on the same version but as `implementation`, so it is not exposed transitively. + implementation("androidx.core:core-ktx:1.13.1") + testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.mockito:mockito-core:5.0.0") } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c312d8b..da1991c 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,13 @@ + + + + diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt index d46a5a4..5f231ad 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt @@ -17,8 +17,8 @@ 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 com.rees46.rees46_flutter_sdk.push.Rees46PushNotifier import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -116,31 +116,29 @@ class Rees46FlutterSdkPlugin : needReInitialization = config.needReInitialization, ) - // Mirror REES46 entrypoint behaviour: show notifications on message. + Rees46PushNotifier.ensureChannel(applicationContext) + + // Show a heads-up BigPicture notification on message (pop-up, image, tap opens the app) + // — the native equivalent of the REES46 React Native demo. This replaces the SDK's + // built-in collapsed/low-importance custom-view notification. The push is also forwarded + // to Dart (onPushReceived / onPushDelivered) so the host app can react. sdk.setOnMessageListener { data -> + android.util.Log.d( + Rees46PushNotifier.TAG, + "onMessage (plugin listener) id=${data.id}", + ) val payload = data.toPayload() - flutterApi?.onPushReceived(payload) { _ -> } + // The listener fires on an FCM background thread, but flutterApi is a Pigeon + // channel whose methods are @UiThread — calling them off the main thread throws + // and would abort before the notification is posted. Hop to Main (coroutineScope + // is Dispatchers.Main) for every flutterApi call, download+post the notification + // off the main thread, and never let a flutterApi failure block the display. coroutineScope.launch { - val (images, hasError) = withContext(Dispatchers.IO) { - NotificationImageHelper.loadBitmaps(urls = data.image) + runCatching { flutterApi?.onPushReceived(payload) { _ -> } } + withContext(Dispatchers.IO) { + Rees46PushNotifier.show(applicationContext, data) } - 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) { _ -> } + runCatching { flutterApi?.onPushDelivered(payload) { _ -> } } } } diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt new file mode 100644 index 0000000..411d0c2 --- /dev/null +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt @@ -0,0 +1,64 @@ +package com.rees46.rees46_flutter_sdk.push + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.util.Log +import com.personalization.SDK + +/** + * Installs the push display listener at process start — including the cold process FCM spins up + * just to deliver a push when the app has been swiped away and no Flutter engine is running. + * + * A [ContentProvider.onCreate] runs before `Application.onCreate` and before the SDK's + * messaging services, the same auto-initialization trick `FirebaseInitProvider` uses. We only + * attach an [com.personalization.OnMessageListener] to the SDK singleton (no full + * [SDK.initialize] — display needs none): when the message arrives, the SDK routes it to this + * listener and the heads-up BigPicture is posted. Tracking the "received" event needs an + * initialized SDK and is skipped in this cold path; the click is still tracked once the user taps + * and the app starts and initializes. + * + * On a normal launch this listener is replaced by the plugin's full listener (which also forwards + * the push to Dart) when Dart calls `initialize()` on the same singleton. + */ +class Rees46PushInitProvider : ContentProvider() { + + override fun onCreate(): Boolean { + val context = context?.applicationContext ?: return false + try { + Rees46PushNotifier.ensureChannel(context) + SDK.instance.setOnMessageListener { data -> + Log.d(Rees46PushNotifier.TAG, "onMessage (provider listener) id=${data.id}") + // Off the main thread: show() downloads the image synchronously. + Thread { Rees46PushNotifier.show(context, data) }.start() + } + Log.d(Rees46PushNotifier.TAG, "provider installed cold-start push listener") + } catch (t: Throwable) { + // Never let push bootstrap crash the host process at startup. + Log.e(Rees46PushNotifier.TAG, "provider failed to install push listener", t) + } + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt new file mode 100644 index 0000000..b5839ba --- /dev/null +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt @@ -0,0 +1,121 @@ +package com.rees46.rees46_flutter_sdk.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.personalization.resources.NotificationResources +import com.personalization.sdk.data.models.dto.notification.NotificationData +import java.net.URL + +/** + * Posts a heads-up BigPicture notification from REES46 push data — the native equivalent of the + * REES46 React Native demo's notifee BIGPICTURE notification (and of the android-sdk demo's + * display). + * + * Why not the SDK's built-in [com.personalization.features.notification.presentation.helpers.NotificationHelper]: + * it posts a collapsed custom-view notification on a LOW-importance channel with no content intent, + * so there is no heads-up pop-up and tapping does nothing. This presenter posts a standard + * heads-up BigPicture instead: title/body are visible without expanding, the image is shown as the + * big picture, and tapping opens the app (carrying the push type/id so the click is tracked). + * + * [show] downloads images synchronously, so it must be called off the main thread. + */ +object Rees46PushNotifier { + + /** Logcat tag — unconditional, so push display can be traced without SDK debug mode. */ + const val TAG = "Rees46Push" + + /** Distinct from the SDK's own LOW-importance "notification_channel" so HIGH importance sticks. */ + const val CHANNEL_ID = "rees46_push" + private const val CHANNEL_NAME = "Push notifications" + + /** Idempotent. Creates the HIGH-importance channel so pushes appear as a heads-up pop-up. */ + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + ) + ContextCompat.getSystemService(context, NotificationManager::class.java) + ?.createNotificationChannel(channel) + } + } + + /** Builds and posts the notification. Must run off the main thread (downloads images). */ + fun show(context: Context, data: NotificationData) { + Log.d(TAG, "show() title=${data.title} body=${data.body} image=${data.image}") + try { + ensureChannel(context) + + val bigPicture = data.image?.split(",")?.firstOrNull()?.trim() + ?.takeIf { it.isNotEmpty() }?.let(::loadBitmap) + val largeIcon = data.icon?.trim()?.takeIf { it.isNotEmpty() }?.let(::loadBitmap) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(NotificationResources.NOTIFICATION_ICON) + .setContentTitle(data.title) + .setContentText(data.body) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentIntent(buildContentIntent(context, data)) + + if (largeIcon != null) builder.setLargeIcon(largeIcon) + if (bigPicture != null) { + builder.setStyle( + NotificationCompat.BigPictureStyle() + .bigPicture(bigPicture) + .bigLargeIcon(null as Bitmap?), + ) + } + + val id = (data.title.orEmpty() + data.body.orEmpty()).hashCode() + val manager = ContextCompat.getSystemService(context, NotificationManager::class.java) + if (manager == null) { + Log.e(TAG, "NotificationManager unavailable — cannot post notification") + return + } + manager.notify(id, builder.build()) + Log.d(TAG, "notify() posted id=$id (bigPicture=${bigPicture != null})") + } catch (t: Throwable) { + Log.e(TAG, "Failed to post notification", t) + } + } + + /** + * Tapping opens the host launcher activity, carrying the push type/id as extras so the plugin's + * launch-intent handler reports the click to the SDK (and to Dart via onPushClicked). The keys + * match the SDK's NotificationConstants (NOTIFICATION_TYPE / NOTIFICATION_ID). + */ + private fun buildContentIntent(context: Context, data: NotificationData): PendingIntent { + val intent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { + putExtra("NOTIFICATION_TYPE", data.type) + putExtra("NOTIFICATION_ID", data.id) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + ?: Intent() + return PendingIntent.getActivity( + context, + (data.id ?: (data.title.orEmpty() + data.body.orEmpty())).hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun loadBitmap(url: String): Bitmap? = try { + URL(url).openStream().use { BitmapFactory.decodeStream(it) } + } catch (e: Exception) { + null + } +} From ad2309cb8684d904cfc3c4c08a48501659518dcd Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Thu, 2 Jul 2026 10:16:26 +0400 Subject: [PATCH 05/12] test: add Patrol integration tests for catalog/search + Android CI Cover catalog (profile/counters/category/collection), search (blank/instant/full), recommendation, product info and loyalty against the live REES46 API, and run them on an emulator in CI (.github/workflows/integration-android.yaml). Example app changes that make it reliably testable and match the native demo: - request POST_NOTIFICATIONS from a Dart post-frame callback via a platform channel instead of MainActivity.onCreate, so the system dialog can't race Patrol's app-service handshake and hang the run - use SingleChildScrollView + Column (not a lazy ListView) so every field and label is built and findable while off-screen - pre-fill demo inputs with valid shop c1140c data; fix the setProfile email and the trackEvent custom field key Test helpers (patrol_setup.dart): dismiss the notification dialog the Patrol-native way; tap the search buttons by key (their text collides with the card titles) and wait on the result/error label instead of the button. --- .github/workflows/integration-android.yaml | 69 ++ .../MainActivity.kt | 48 +- .../integration_test/catalog_sdk_test.dart | 172 +++++ example/integration_test/init_flow_test.dart | 5 + .../integration_test/loyalty_call_test.dart | 25 +- .../integration_test/loyalty_sdk_test.dart | 3 + example/integration_test/patrol_setup.dart | 51 ++ .../integration_test/patrol_smoke_test.dart | 4 + .../product_info_sdk_test.dart | 7 + .../products_list_sdk_test.dart | 3 + .../integration_test/profile_sdk_test.dart | 3 + example/integration_test/push_token_test.dart | 5 + .../recommendation_sdk_test.dart | 13 +- .../search_blank_sdk_test.dart | 27 +- .../search_instant_sdk_test.dart | 49 +- example/integration_test/search_sdk_test.dart | 31 +- example/integration_test/test_config.dart | 27 +- example/lib/main.dart | 661 ++++++++++++++---- 18 files changed, 987 insertions(+), 216 deletions(-) create mode 100644 .github/workflows/integration-android.yaml create mode 100644 example/integration_test/catalog_sdk_test.dart create mode 100644 example/integration_test/patrol_setup.dart diff --git a/.github/workflows/integration-android.yaml b/.github/workflows/integration-android.yaml new file mode 100644 index 0000000..1461e4e --- /dev/null +++ b/.github/workflows/integration-android.yaml @@ -0,0 +1,69 @@ +name: Android integration tests + +on: + pull_request: + types: + - opened + - reopened + - synchronize + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + patrol-android: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + # Hardware acceleration for the Android emulator on ubuntu runners. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.0' + channel: stable + + - name: Get dependencies + run: flutter pub get + + # Pin to match the `patrol` package major in example/pubspec.yaml (^4.x). + - name: Install patrol_cli + run: | + dart pub global activate patrol_cli 4.3.1 + echo "$HOME/.pub-cache/bin" >> "$GITHUB_PATH" + + # Boots an emulator, runs the whole integration_test/ suite on it, tears down. + # The tests drive the real example app against the REES46 demo shop (c1140c…), + # so they exercise the Kotlin bridge + real backend — what unit tests can't. + - name: Run Patrol integration tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: default + arch: x86_64 + profile: pixel_6 + ram-size: 4096M + cores: 3 + emulator-options: >- + -no-window -no-snapshot -noaudio -no-boot-anim + -gpu swiftshader_indirect -camera-back none + working-directory: example + script: | + flutter pub get + patrol test --verbose 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 index e9ff4da..bc81312 100644 --- 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 @@ -1,5 +1,51 @@ package com.rees46.rees46_flutter_sdk_example +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + // The Flutter layer asks for the notification permission from a post-frame + // callback (see main.dart) instead of from onCreate. Requesting it only after + // the engine and first frame are up keeps the system dialog from racing + // Patrol's app-service handshake during integration tests (which would hang the + // run), while the real app still prompts the user right at startup. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "requestNotificationPermission" -> { + ensureNotificationPermission() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + private fun ensureNotificationPermission() { + // On Android 13+ POST_NOTIFICATIONS is a runtime permission; without it the OS + // silently drops every notification. The native REES46 demo requests it the same + // way, so the Flutter host must too, otherwise pushes never appear. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!granted) { + requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQUEST_CODE_POST_NOTIFICATIONS, + ) + } + } + } + + private companion object { + const val CHANNEL = "rees46_sdk_example/platform" + const val REQUEST_CODE_POST_NOTIFICATIONS = 1001 + } +} diff --git a/example/integration_test/catalog_sdk_test.dart b/example/integration_test/catalog_sdk_test.dart new file mode 100644 index 0000000..dda4272 --- /dev/null +++ b/example/integration_test/catalog_sdk_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:rees46_sdk_example/main.dart' as app; + +import 'patrol_setup.dart'; + +import 'test_config.dart'; + +Future _initializeSdk(PatrolIntegrationTester $) async { + await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); + 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 ?? ''; +} + +bool _exists(String key) => find.byKey(Key(key)).evaluate().isNotEmpty; + +/// Polls until either the result or the error label for a method is rendered, +/// so the assertion does not depend on whether the test shop happens to have the +/// requested category/collection — only on the call completing without crashing. +Future _waitForTerminalState( + PatrolIntegrationTester $, + String resultKey, + String errorKey, +) async { + for (var i = 0; i < 60; i++) { + if (_exists(resultKey) || _exists(errorKey)) return; + await $.tester.pump(const Duration(milliseconds: 500)); + } +} + +void main() { + // --------------------------------------------------------------------------- + // getProfile — the session profile always resolves after init. + // --------------------------------------------------------------------------- + patrolTest('getProfile — returns the session profile', ($) async { + await _initializeSdk($); + + await $(const Key('btn_catalog_profile')).scrollTo(); + await $(const Key('btn_catalog_profile')).tap(); + + await $( + const Key('lbl_catalog_profile'), + ).waitUntilExists(timeout: const Duration(seconds: 30)); + + expect(_exists('lbl_catalog_profile_error'), isFalse); + expect(_labelText($, 'lbl_catalog_profile'), contains('id=')); + }); + + // --------------------------------------------------------------------------- + // getProductCounters — returns counters for any item id (zeros if untracked). + // --------------------------------------------------------------------------- + patrolTest('getProductCounters — returns counters for an item', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_catalog_counter_item')), + TestConfig.productId, + ); + await $.tester.pump(); + + await $(const Key('btn_catalog_counters')).scrollTo(); + await $(const Key('btn_catalog_counters')).tap(); + + await $( + const Key('lbl_catalog_counters'), + ).waitUntilExists(timeout: const Duration(seconds: 30)); + + expect(_exists('lbl_catalog_counters_error'), isFalse); + expect(_labelText($, 'lbl_catalog_counters'), contains('now.view=')); + }); + + patrolTest('getProductCounters — empty item is a no-op', ($) async { + await _initializeSdk($); + + // The field is pre-filled for manual use — clear it to exercise the empty case. + await $.tester.enterText( + find.byKey(const Key('field_catalog_counter_item')), + '', + ); + await $.tester.pump(); + + await $(const Key('btn_catalog_counters')).scrollTo(); + await $(const Key('btn_catalog_counters')).tap(); + await $.pumpAndSettle(); + + expect(_exists('lbl_catalog_counters'), isFalse); + expect(_exists('lbl_catalog_counters_error'), isFalse); + }); + + // --------------------------------------------------------------------------- + // getCategory — round-trips (a listing if the category exists, else an error). + // --------------------------------------------------------------------------- + patrolTest('getCategory — completes the round-trip', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_catalog_category')), + 'naushniki', + ); + await $.tester.pump(); + + await $(const Key('btn_catalog_category')).scrollTo(); + await $(const Key('btn_catalog_category')).tap(); + + await _waitForTerminalState( + $, + 'lbl_catalog_category', + 'lbl_catalog_category_error', + ); + + expect( + _exists('lbl_catalog_category') || _exists('lbl_catalog_category_error'), + isTrue, + ); + }); + + patrolTest('getCategory — empty category is a no-op', ($) async { + await _initializeSdk($); + + // The field is pre-filled for manual use — clear it to exercise the empty case. + await $.tester.enterText( + find.byKey(const Key('field_catalog_category')), + '', + ); + await $.tester.pump(); + + await $(const Key('btn_catalog_category')).scrollTo(); + await $(const Key('btn_catalog_category')).tap(); + await $.pumpAndSettle(); + + expect(_exists('lbl_catalog_category'), isFalse); + expect(_exists('lbl_catalog_category_error'), isFalse); + }); + + // --------------------------------------------------------------------------- + // getCollection — round-trips (products if the collection exists, else error). + // --------------------------------------------------------------------------- + patrolTest('getCollection — completes the round-trip', ($) async { + await _initializeSdk($); + + await $.tester.enterText( + find.byKey(const Key('field_catalog_collection')), + '1', + ); + await $.tester.pump(); + + await $(const Key('btn_catalog_collection')).scrollTo(); + await $(const Key('btn_catalog_collection')).tap(); + + await _waitForTerminalState( + $, + 'lbl_catalog_collection', + 'lbl_catalog_collection_error', + ); + + expect( + _exists('lbl_catalog_collection') || + _exists('lbl_catalog_collection_error'), + isTrue, + ); + }); +} diff --git a/example/integration_test/init_flow_test.dart b/example/integration_test/init_flow_test.dart index 86567ec..6cda391 100644 --- a/example/integration_test/init_flow_test.dart +++ b/example/integration_test/init_flow_test.dart @@ -3,9 +3,12 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + void main() { patrolTest('auto-initializes on startup with hardcoded config', ($) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', @@ -16,6 +19,7 @@ void main() { $, ) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', @@ -28,6 +32,7 @@ void main() { 'Re-initialize button re-runs initialization and returns to Initialized', ($) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', diff --git a/example/integration_test/loyalty_call_test.dart b/example/integration_test/loyalty_call_test.dart index 7f27e52..0d1e467 100644 --- a/example/integration_test/loyalty_call_test.dart +++ b/example/integration_test/loyalty_call_test.dart @@ -1,18 +1,21 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; +import 'package:patrol/patrol.dart'; import 'package:rees46_sdk/rees46_sdk.dart'; -/// Real-network integration test for the loyalty methods. +/// Real-network integration test for the loyalty methods, exercised at the SDK +/// level (no demo UI). It complements [loyalty_sdk_test], which drives the same +/// methods through the example app's UI. /// /// Drives the actual native SDK (Android `loyaltyManager`, iOS /// `joinLoyalty`/`getLoyaltyStatus`) against the live REES46 API using a -/// loyalty-enabled shop. Run on a booted simulator/emulator: +/// loyalty-enabled shop. Runs as part of the Patrol suite. /// -/// flutter test integration_test/loyalty_call_test.dart -d [device-id] +/// NOTE: this must be a `patrolTest`, not a plain `testWidgets` with +/// `IntegrationTestWidgetsFlutterBinding` — Patrol bundles every *_test.dart in +/// this directory and initializes its own `PatrolBinding`; a second binding +/// would conflict and hang the whole run during test exploration. void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - const shopId = 'c1140c8254976de297c3caf971701a'; const apiDomain = 'api.rees46.ru'; const phone = '79991234567'; @@ -20,10 +23,12 @@ void main() { Future initSdk() async { final sdk = PersonalizationSdk(); await sdk.initialize( - const SdkInitConfig( + SdkInitConfig( shopId: shopId, apiDomain: apiDomain, - stream: kIsWeb ? 'web' : 'ios', + stream: defaultTargetPlatform == TargetPlatform.android + ? 'android' + : 'ios', enableLogs: true, autoSendPushToken: false, sendAdvertisingId: false, @@ -41,7 +46,7 @@ void main() { return sdk; } - testWidgets('joinLoyalty — real call returns success', (tester) async { + patrolTest('joinLoyalty — real call returns success', ($) async { final sdk = await initSdk(); final response = await sdk.joinLoyalty(phone: phone); @@ -51,7 +56,7 @@ void main() { expect(response.status, equals('success')); }); - testWidgets('getLoyaltyStatus — real call returns success', (tester) async { + patrolTest('getLoyaltyStatus — real call returns success', ($) async { final sdk = await initSdk(); final response = await sdk.getLoyaltyStatus(phone); diff --git a/example/integration_test/loyalty_sdk_test.dart b/example/integration_test/loyalty_sdk_test.dart index 1a1080d..3f66ec5 100644 --- a/example/integration_test/loyalty_sdk_test.dart +++ b/example/integration_test/loyalty_sdk_test.dart @@ -4,8 +4,11 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); diff --git a/example/integration_test/patrol_setup.dart b/example/integration_test/patrol_setup.dart new file mode 100644 index 0000000..d0acde5 --- /dev/null +++ b/example/integration_test/patrol_setup.dart @@ -0,0 +1,51 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +/// Waits until either [resultKey] or [errorKey] is present, so assertions run +/// only after the async SDK call has rendered its outcome. +/// +/// Prefer this over waiting on a button's label when that label isn't unique on +/// the page — e.g. a card whose title text equals its button text. Waiting on +/// such text resolves immediately against the always-visible title and races +/// the result. Tap the button by its Key, then wait on the result/error label. +Future waitForResultOrError( + PatrolIntegrationTester $, + String resultKey, + String errorKey, { + int maxPumps = 60, +}) async { + for (var i = 0; i < maxPumps; i++) { + final done = + find.byKey(Key(resultKey)).evaluate().isNotEmpty || + find.byKey(Key(errorKey)).evaluate().isNotEmpty; + if (done) return; + await $.tester.pump(const Duration(milliseconds: 500)); + } +} + +/// Dismisses the Android 13+ POST_NOTIFICATIONS permission dialog that the +/// example app requests once at startup (see MainActivity). +/// +/// This is deliberately defensive so it can never wedge the whole suite: +/// * `isPermissionDialogVisible` is given a real timeout so it tolerates the +/// dialog appearing a moment after the app launches (a race the 2s default +/// loses), instead of returning `false` too early. +/// * The whole thing is wrapped so a UiAutomator hiccup (or a build where the +/// dialog never shows, e.g. permission already granted) is a no-op rather +/// than a hang or a failure. +/// +/// The OS only shows the dialog on the first app launch of an instrumentation +/// run, so for every test after the first this is a cheap no-op. +Future dismissStartupPermissionDialog( + PatrolIntegrationTester $, { + Duration timeout = const Duration(seconds: 5), +}) async { + try { + if (await $.platform.mobile.isPermissionDialogVisible(timeout: timeout)) { + await $.platform.mobile.grantPermissionWhenInUse(); + } + } catch (_) { + // Permission handling must never fail or hang a test. + } +} diff --git a/example/integration_test/patrol_smoke_test.dart b/example/integration_test/patrol_smoke_test.dart index b02c9dd..75c20f8 100644 --- a/example/integration_test/patrol_smoke_test.dart +++ b/example/integration_test/patrol_smoke_test.dart @@ -2,9 +2,12 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + void main() { patrolTest('app launches and all key sections are visible', ($) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $('REES46 SDK init demo').waitUntilVisible(); await $('Initialization').waitUntilVisible(); @@ -18,6 +21,7 @@ void main() { 'auto-initializes on startup and exposes the Re-initialize button', ($) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); // The demo uses hardcoded config and initializes itself on launch. await $( diff --git a/example/integration_test/product_info_sdk_test.dart b/example/integration_test/product_info_sdk_test.dart index fc189ae..eba0445 100644 --- a/example/integration_test/product_info_sdk_test.dart +++ b/example/integration_test/product_info_sdk_test.dart @@ -4,10 +4,13 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + import 'test_config.dart'; Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); @@ -43,6 +46,10 @@ void main() { patrolTest('getProductInfo — empty ID is no-op in the UI', ($) async { await _initializeSdk($); + // The field is pre-filled for manual use — clear it to exercise the empty case. + await $.tester.enterText(find.byKey(const Key('field_product_id')), ''); + await $.tester.pump(); + await $('Get Product Info').scrollTo(); await $('Get Product Info').tap(); await $.pumpAndSettle(); diff --git a/example/integration_test/products_list_sdk_test.dart b/example/integration_test/products_list_sdk_test.dart index df095cd..2b1f3d1 100644 --- a/example/integration_test/products_list_sdk_test.dart +++ b/example/integration_test/products_list_sdk_test.dart @@ -4,8 +4,11 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); diff --git a/example/integration_test/profile_sdk_test.dart b/example/integration_test/profile_sdk_test.dart index 8d02394..4ee2229 100644 --- a/example/integration_test/profile_sdk_test.dart +++ b/example/integration_test/profile_sdk_test.dart @@ -4,8 +4,11 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); diff --git a/example/integration_test/push_token_test.dart b/example/integration_test/push_token_test.dart index 402e1a0..c2a5ce6 100644 --- a/example/integration_test/push_token_test.dart +++ b/example/integration_test/push_token_test.dart @@ -2,15 +2,19 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + void main() { patrolTest('push token section is visible on launch', ($) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $('Stored push token').waitUntilVisible(); }); patrolTest('Refresh and Copy controls are visible', ($) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $('Refresh').waitUntilVisible(); await $('Copy').waitUntilVisible(); @@ -18,6 +22,7 @@ void main() { patrolTest('refreshing the token after auto-init does not crash', ($) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', diff --git a/example/integration_test/recommendation_sdk_test.dart b/example/integration_test/recommendation_sdk_test.dart index 36a57ed..c195a4a 100644 --- a/example/integration_test/recommendation_sdk_test.dart +++ b/example/integration_test/recommendation_sdk_test.dart @@ -4,10 +4,13 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + import 'test_config.dart'; Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); @@ -43,7 +46,9 @@ void main() { final title = _labelText($, 'lbl_rec_title'); expect(title, isNot(contains('Error'))); expect(title, startsWith('Title:')); - }); + // Skipped until a real recommender block code for the test shop is set in + // TestConfig — the placeholder returns 404. Auto-runs once a code is set. + }, skip: TestConfig.recommendationBlockCode == 'your_block_code'); patrolTest('getRecommendation — product count is non-negative', ($) async { await _initializeSdk($); @@ -62,12 +67,14 @@ void main() { final n = int.tryParse(countText.replaceFirst('Products: ', '')); expect(n, isNotNull); expect(n, greaterThanOrEqualTo(0)); - }); + }, skip: TestConfig.recommendationBlockCode == 'your_block_code'); patrolTest('getRecommendation — empty block code does not crash', ($) async { await _initializeSdk($); - // Leave block code field empty and tap. + // The field is pre-filled for manual use — clear it to exercise the empty case. + await $.tester.enterText(find.byKey(const Key('field_rec_block_code')), ''); + await $.tester.pump(); await $('Get Recommendations').scrollTo(); await $('Get Recommendations').tap(); await $.pumpAndSettle(); diff --git a/example/integration_test/search_blank_sdk_test.dart b/example/integration_test/search_blank_sdk_test.dart index 4b4af85..f389466 100644 --- a/example/integration_test/search_blank_sdk_test.dart +++ b/example/integration_test/search_blank_sdk_test.dart @@ -4,8 +4,11 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); @@ -21,9 +24,9 @@ void main() { patrolTest('searchBlank — returns products and suggests counts', ($) async { await _initializeSdk($); - await $('Search Blank').scrollTo(); - await $('Search Blank').tap(); - await $('Search Blank').waitUntilVisible(); + await $(const Key('btn_search_blank')).scrollTo(); + await $(const Key('btn_search_blank')).tap(); + await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); expect(find.byKey(const Key('lbl_search_blank_error')), findsNothing); @@ -44,14 +47,14 @@ void main() { patrolTest('searchBlank — can be called multiple times', ($) async { await _initializeSdk($); - await $('Search Blank').scrollTo(); - await $('Search Blank').tap(); - await $('Search Blank').waitUntilVisible(); + await $(const Key('btn_search_blank')).scrollTo(); + await $(const Key('btn_search_blank')).tap(); + await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); final firstProducts = _labelText($, 'lbl_search_blank_products'); - await $('Search Blank').scrollTo(); - await $('Search Blank').tap(); - await $('Search Blank').waitUntilVisible(); + await $(const Key('btn_search_blank')).scrollTo(); + await $(const Key('btn_search_blank')).tap(); + await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); final secondProducts = _labelText($, 'lbl_search_blank_products'); expect(firstProducts, equals(secondProducts)); @@ -60,9 +63,9 @@ void main() { patrolTest('searchBlank — no error on first call after init', ($) async { await _initializeSdk($); - await $('Search Blank').scrollTo(); - await $('Search Blank').tap(); - await $('Search Blank').waitUntilVisible(); + await $(const Key('btn_search_blank')).scrollTo(); + await $(const Key('btn_search_blank')).tap(); + await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); 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 index f6a0f38..8fe4f76 100644 --- a/example/integration_test/search_instant_sdk_test.dart +++ b/example/integration_test/search_instant_sdk_test.dart @@ -4,10 +4,13 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + import 'test_config.dart'; Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); @@ -31,8 +34,13 @@ void main() { ); await $.tester.pump(); - await $('Search Instant').tap(); - await $('Search Instant').waitUntilVisible(); + await $(const Key('btn_search_instant')).scrollTo(); + await $(const Key('btn_search_instant')).tap(); + await waitForResultOrError( + $, + 'lbl_search_instant_total', + 'lbl_search_instant_error', + ); expect(find.byKey(const Key('lbl_search_instant_error')), findsNothing); @@ -46,7 +54,15 @@ void main() { patrolTest('searchInstant — empty query is no-op in the UI', ($) async { await _initializeSdk($); - await $('Search Instant').tap(); + // The field is pre-filled for manual use — clear it to exercise the empty case. + await $.tester.enterText( + find.byKey(const Key('field_search_instant_query')), + '', + ); + await $.tester.pump(); + + await $(const Key('btn_search_instant')).scrollTo(); + await $(const Key('btn_search_instant')).tap(); await $.pumpAndSettle(); expect(find.byKey(const Key('lbl_search_instant_total')), findsNothing); @@ -62,12 +78,22 @@ void main() { ); await $.tester.pump(); - await $('Search Instant').tap(); - await $('Search Instant').waitUntilVisible(); + await $(const Key('btn_search_instant')).scrollTo(); + await $(const Key('btn_search_instant')).tap(); + await waitForResultOrError( + $, + 'lbl_search_instant_total', + 'lbl_search_instant_error', + ); final first = _labelText($, 'lbl_search_instant_total'); - await $('Search Instant').tap(); - await $('Search Instant').waitUntilVisible(); + await $(const Key('btn_search_instant')).scrollTo(); + await $(const Key('btn_search_instant')).tap(); + await waitForResultOrError( + $, + 'lbl_search_instant_total', + 'lbl_search_instant_error', + ); final second = _labelText($, 'lbl_search_instant_total'); expect(first, equals(second)); @@ -82,8 +108,13 @@ void main() { ); await $.tester.pump(); - await $('Search Instant').tap(); - await $('Search Instant').waitUntilVisible(); + await $(const Key('btn_search_instant')).scrollTo(); + await $(const Key('btn_search_instant')).tap(); + await waitForResultOrError( + $, + 'lbl_search_instant_total', + 'lbl_search_instant_error', + ); final hasTotal = find .byKey(const Key('lbl_search_instant_total')) diff --git a/example/integration_test/search_sdk_test.dart b/example/integration_test/search_sdk_test.dart index 36f7bbc..d6b330f 100644 --- a/example/integration_test/search_sdk_test.dart +++ b/example/integration_test/search_sdk_test.dart @@ -4,10 +4,13 @@ import 'package:patrol/patrol.dart'; import 'package:rees46_sdk_example/main.dart' as app; +import 'patrol_setup.dart'; + import 'test_config.dart'; Future _initializeSdk(PatrolIntegrationTester $) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); await $( 'Status: Initialized', ).waitUntilExists(timeout: const Duration(seconds: 30)); @@ -29,9 +32,9 @@ void main() { ); await $.tester.pump(); - await $('Search').scrollTo(); - await $('Search').tap(); - await $('Search').waitUntilVisible(); + await $(const Key('btn_search')).scrollTo(); + await $(const Key('btn_search')).tap(); + await waitForResultOrError($, 'lbl_search_total', 'lbl_search_error'); expect(find.byKey(const Key('lbl_search_error')), findsNothing); @@ -45,8 +48,8 @@ void main() { patrolTest('searchFull — empty query is no-op in the UI', ($) async { await _initializeSdk($); - await $('Search').scrollTo(); - await $('Search').tap(); + await $(const Key('btn_search')).scrollTo(); + await $(const Key('btn_search')).tap(); await $.pumpAndSettle(); expect(find.byKey(const Key('lbl_search_total')), findsNothing); @@ -62,14 +65,14 @@ void main() { ); await $.tester.pump(); - await $('Search').scrollTo(); - await $('Search').tap(); - await $('Search').waitUntilVisible(); + await $(const Key('btn_search')).scrollTo(); + await $(const Key('btn_search')).tap(); + await waitForResultOrError($, 'lbl_search_total', 'lbl_search_error'); final first = _labelText($, 'lbl_search_total'); - await $('Search').scrollTo(); - await $('Search').tap(); - await $('Search').waitUntilVisible(); + await $(const Key('btn_search')).scrollTo(); + await $(const Key('btn_search')).tap(); + await waitForResultOrError($, 'lbl_search_total', 'lbl_search_error'); final second = _labelText($, 'lbl_search_total'); expect(first, equals(second)); @@ -86,9 +89,9 @@ void main() { ); await $.tester.pump(); - await $('Search').scrollTo(); - await $('Search').tap(); - await $('Search').waitUntilVisible(); + await $(const Key('btn_search')).scrollTo(); + await $(const Key('btn_search')).tap(); + await waitForResultOrError($, 'lbl_search_total', 'lbl_search_error'); final hasTotal = find .byKey(const Key('lbl_search_total')) diff --git a/example/integration_test/test_config.dart b/example/integration_test/test_config.dart index 78f06bb..84a4ac3 100644 --- a/example/integration_test/test_config.dart +++ b/example/integration_test/test_config.dart @@ -1,19 +1,22 @@ -/// Test credentials for integration tests. +/// Test data for the 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. +/// These values must exist in the shop the example app initializes with +/// (see `_shopId` in example/lib/main.dart → `c1140c…`, the technodom.kz demo +/// shop). They are demo-shop identifiers, not credentials. class TestConfig { - static const shopId = '357382bf66ac0ce2f1722677c59511'; + /// The shop the example app initializes with (kept here for reference; the + /// app hardcodes it, the tests drive the app). + static const shopId = 'c1140c8254976de297c3caf971701a'; 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 recommender block code that exists in the test shop (the "Популярные" + /// / popular block for `c1140c…`, returns ~20 products). + static const recommendationBlockCode = 'e6249bb15043644bf25b135006149962'; - /// A search query that returns at least one result in your test shop. - static const searchQuery = 'пудра-бронзер'; + /// A search query that returns results in the test shop (Logitech products + /// exist, e.g. item 868). + static const searchQuery = 'Logitech'; - /// A product ID that exists in your test shop. - static const productId = '486'; + /// A product ID that exists in the test shop (Logitech H150 headset). + static const productId = '868'; } diff --git a/example/lib/main.dart b/example/lib/main.dart index 19590e5..8011b8f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -32,6 +32,10 @@ enum InitState { idle, initializing, initialized, failed } class _InitPageState extends State { final _sdk = PersonalizationSdk(); + // Platform channel used to ask the host for the Android 13+ notification + // permission. See [_requestNotificationPermission]. + static const _platformChannel = MethodChannel('rees46_sdk_example/platform'); + // Hardcoded demo credentials — the demo does not need editable init inputs. static const _shopId = 'c1140c8254976de297c3caf971701a'; static const _apiDomain = 'api.rees46.ru'; @@ -59,20 +63,22 @@ class _InitPageState extends State { String? _profileStatus; // Recommendation state - final _recBlockController = TextEditingController(); + final _recBlockController = TextEditingController( + text: 'e6249bb15043644bf25b135006149962', + ); String? _recTitle; int? _recProductCount; String? _recError; bool _recLoading = false; // Search (full) state - final _searchQueryController = TextEditingController(); + final _searchQueryController = TextEditingController(text: 'Logitech'); int? _searchTotal; String? _searchError; bool _searchLoading = false; // Product info state - final _productIdController = TextEditingController(); + final _productIdController = TextEditingController(text: '868'); String? _productInfoName; String? _productInfoError; bool _productInfoLoading = false; @@ -89,7 +95,7 @@ class _InitPageState extends State { bool _searchBlankLoading = false; // Search instant state - final _searchInstantQueryController = TextEditingController(); + final _searchInstantQueryController = TextEditingController(text: 'Logitech'); int? _searchInstantTotal; String? _searchInstantError; bool _searchInstantLoading = false; @@ -100,6 +106,26 @@ class _InitPageState extends State { String? _loyaltyError; bool _loyaltyLoading = false; + // Catalog state (profile / product counters / category / collection) + String? _catalogProfile; + String? _catalogProfileError; + bool _catalogProfileLoading = false; + + final _catalogCounterItemController = TextEditingController(text: '868'); + String? _catalogCounters; + String? _catalogCountersError; + bool _catalogCountersLoading = false; + + final _catalogCategoryController = TextEditingController(text: 'naushniki'); + String? _catalogCategory; + String? _catalogCategoryError; + bool _catalogCategoryLoading = false; + + final _catalogCollectionController = TextEditingController(text: '1'); + String? _catalogCollection; + String? _catalogCollectionError; + bool _catalogCollectionLoading = false; + @override void dispose() { _recBlockController.dispose(); @@ -107,6 +133,9 @@ class _InitPageState extends State { _searchQueryController.dispose(); _searchInstantQueryController.dispose(); _loyaltyPhoneController.dispose(); + _catalogCounterItemController.dispose(); + _catalogCategoryController.dispose(); + _catalogCollectionController.dispose(); super.dispose(); } @@ -123,6 +152,23 @@ class _InitPageState extends State { // 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(); + // Ask for the notification permission after the first frame. Triggering it from + // Dart (rather than MainActivity.onCreate) guarantees it runs after Patrol's + // app-service handshake, so the system dialog never hangs an integration test. + WidgetsBinding.instance.addPostFrameCallback((_) { + _requestNotificationPermission(); + }); + } + + Future _requestNotificationPermission() async { + try { + await _platformChannel.invokeMethod( + 'requestNotificationPermission', + ); + } catch (_) { + // Best-effort: the app still runs without it (pushes just won't be shown + // until the permission is granted), and non-Android hosts ignore it. + } } Future _initialize() async { @@ -200,8 +246,10 @@ class _InitPageState extends State { Future _setProfile() async { try { await _sdk.setProfile( + // Not example.com — the REES46 backend rejects RFC 2606 reserved test + // domains with 400 "Invalid email or missing". const ProfileParams( - email: 'test@example.com', + email: 'tester@rees46.com', firstName: 'Test', gender: ProfileGender.male, ), @@ -390,12 +438,105 @@ class _InitPageState extends State { } } + Future _getCatalogProfile() async { + setState(() { + _catalogProfileLoading = true; + _catalogProfileError = null; + _catalogProfile = null; + }); + try { + final p = await _sdk.getProfile(); + setState(() { + _catalogProfile = + 'id=${p.id ?? '-'}, hasEmail=${p.hasEmail ?? false}, ' + 'gender=${p.gender ?? '-'}, props=${p.customProperties.length}'; + _catalogProfileLoading = false; + }); + } catch (e) { + setState(() { + _catalogProfileError = 'Error: $e'; + _catalogProfileLoading = false; + }); + } + } + + Future _getCatalogCounters() async { + final item = _catalogCounterItemController.text.trim(); + if (item.isEmpty) return; + setState(() { + _catalogCountersLoading = true; + _catalogCountersError = null; + _catalogCounters = null; + }); + try { + final c = await _sdk.getProductCounters(item); + setState(() { + _catalogCounters = + 'now.view=${c.now?.view ?? 0}, daily.view=${c.daily?.view ?? 0}, ' + 'priceDrop=${c.triggers?.priceDrop ?? 0}'; + _catalogCountersLoading = false; + }); + } catch (e) { + setState(() { + _catalogCountersError = 'Error: $e'; + _catalogCountersLoading = false; + }); + } + } + + Future _getCatalogCategory() async { + final category = _catalogCategoryController.text.trim(); + if (category.isEmpty) return; + setState(() { + _catalogCategoryLoading = true; + _catalogCategoryError = null; + _catalogCategory = null; + }); + try { + final r = await _sdk.getCategory(category, limit: 5); + setState(() { + _catalogCategory = + 'total=${r.productsTotal}, products=${r.products.length}'; + _catalogCategoryLoading = false; + }); + } catch (e) { + setState(() { + _catalogCategoryError = 'Error: $e'; + _catalogCategoryLoading = false; + }); + } + } + + Future _getCatalogCollection() async { + final id = _catalogCollectionController.text.trim(); + if (id.isEmpty) return; + setState(() { + _catalogCollectionLoading = true; + _catalogCollectionError = null; + _catalogCollection = null; + }); + try { + final r = await _sdk.getCollection(id); + setState(() { + _catalogCollection = 'products=${r.products.length}'; + _catalogCollectionLoading = false; + }); + } catch (e) { + setState(() { + _catalogCollectionError = 'Error: $e'; + _catalogCollectionLoading = false; + }); + } + } + Future _demoTrackEvent() async { if (_initState != InitState.initialized) return; try { await _sdk.trackEvent( 'flutter_example', - customFields: const {'source': 'example_app'}, + // 'source' is a reserved event key (collides with the SDK's own body + // fields) — use a non-reserved custom key. + customFields: const {'example_source': 'example_app'}, ); if (!mounted) return; ScaffoldMessenger.of( @@ -443,157 +584,190 @@ class _InitPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('REES46 SDK init demo')), - body: ListView( + // A SingleChildScrollView + Column (rather than a lazy ListView) so every + // card and input field is always built and findable by integration tests, + // even when off-screen. A ListView only builds children near the viewport, + // which makes `find.byKey(...)` miss fields below the fold. + body: SingleChildScrollView( 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', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + 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: 12), + _LoyaltyCard( + phoneController: _loyaltyPhoneController, + result: _loyaltyResult, + error: _loyaltyError, + loading: _loyaltyLoading, + enabled: _initState == InitState.initialized, + onJoin: _joinLoyalty, + onStatus: _getLoyaltyStatus, + ), + const SizedBox(height: 12), + _CatalogCard( + enabled: _initState == InitState.initialized, + profile: _catalogProfile, + profileError: _catalogProfileError, + profileLoading: _catalogProfileLoading, + onGetProfile: _getCatalogProfile, + counterItemController: _catalogCounterItemController, + counters: _catalogCounters, + countersError: _catalogCountersError, + countersLoading: _catalogCountersLoading, + onGetCounters: _getCatalogCounters, + categoryController: _catalogCategoryController, + category: _catalogCategory, + categoryError: _catalogCategoryError, + categoryLoading: _catalogCategoryLoading, + onGetCategory: _getCatalogCategory, + collectionController: _catalogCollectionController, + collection: _catalogCollection, + collectionError: _catalogCollectionError, + collectionLoading: _catalogCollectionLoading, + onGetCollection: _getCatalogCollection, ), - ), - 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: 12), - _LoyaltyCard( - phoneController: _loyaltyPhoneController, - result: _loyaltyResult, - error: _loyaltyError, - loading: _loyaltyLoading, - enabled: _initState == InitState.initialized, - onJoin: _joinLoyalty, - onStatus: _getLoyaltyStatus, - ), - const SizedBox(height: 24), - Text('Tracking', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - 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'), - ), - ], + 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'), + ), + ], + ), ), ); } @@ -943,6 +1117,183 @@ class _LoyaltyCard extends StatelessWidget { } } +class _CatalogCard extends StatelessWidget { + final bool enabled; + + final String? profile; + final String? profileError; + final bool profileLoading; + final VoidCallback onGetProfile; + + final TextEditingController counterItemController; + final String? counters; + final String? countersError; + final bool countersLoading; + final VoidCallback onGetCounters; + + final TextEditingController categoryController; + final String? category; + final String? categoryError; + final bool categoryLoading; + final VoidCallback onGetCategory; + + final TextEditingController collectionController; + final String? collection; + final String? collectionError; + final bool collectionLoading; + final VoidCallback onGetCollection; + + const _CatalogCard({ + required this.enabled, + required this.profile, + required this.profileError, + required this.profileLoading, + required this.onGetProfile, + required this.counterItemController, + required this.counters, + required this.countersError, + required this.countersLoading, + required this.onGetCounters, + required this.categoryController, + required this.category, + required this.categoryError, + required this.categoryLoading, + required this.onGetCategory, + required this.collectionController, + required this.collection, + required this.collectionError, + required this.collectionLoading, + required this.onGetCollection, + }); + + @override + Widget build(BuildContext context) { + final errorStyle = TextStyle(color: Theme.of(context).colorScheme.error); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Catalog', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + + // getProfile — no input; returns the current session's profile. + OutlinedButton( + key: const Key('btn_catalog_profile'), + onPressed: (enabled && !profileLoading) ? onGetProfile : null, + child: Text(profileLoading ? 'Loading…' : 'Get Profile'), + ), + if (profile != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_catalog_profile'), profile!), + ], + if (profileError != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_catalog_profile_error'), + profileError!, + style: errorStyle, + ), + ], + const Divider(height: 24), + + // getProductCounters + TextField( + key: const Key('field_catalog_counter_item'), + controller: counterItemController, + decoration: const InputDecoration( + labelText: 'Counters: item ID', + hintText: 'e.g. sku-123', + ), + ), + const SizedBox(height: 8), + OutlinedButton( + key: const Key('btn_catalog_counters'), + onPressed: (enabled && !countersLoading) ? onGetCounters : null, + child: Text( + countersLoading ? 'Loading…' : 'Get Product Counters', + ), + ), + if (counters != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_catalog_counters'), counters!), + ], + if (countersError != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_catalog_counters_error'), + countersError!, + style: errorStyle, + ), + ], + const Divider(height: 24), + + // getCategory + TextField( + key: const Key('field_catalog_category'), + controller: categoryController, + decoration: const InputDecoration( + labelText: 'Category ID', + hintText: 'e.g. 100', + ), + ), + const SizedBox(height: 8), + OutlinedButton( + key: const Key('btn_catalog_category'), + onPressed: (enabled && !categoryLoading) ? onGetCategory : null, + child: Text(categoryLoading ? 'Loading…' : 'Get Category'), + ), + if (category != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_catalog_category'), category!), + ], + if (categoryError != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_catalog_category_error'), + categoryError!, + style: errorStyle, + ), + ], + const Divider(height: 24), + + // getCollection + TextField( + key: const Key('field_catalog_collection'), + controller: collectionController, + decoration: const InputDecoration( + labelText: 'Collection ID', + hintText: 'e.g. 1', + ), + ), + const SizedBox(height: 8), + OutlinedButton( + key: const Key('btn_catalog_collection'), + onPressed: (enabled && !collectionLoading) + ? onGetCollection + : null, + child: Text(collectionLoading ? 'Loading…' : 'Get Collection'), + ), + if (collection != null) ...[ + const SizedBox(height: 8), + Text(key: const Key('lbl_catalog_collection'), collection!), + ], + if (collectionError != null) ...[ + const SizedBox(height: 8), + Text( + key: const Key('lbl_catalog_collection_error'), + collectionError!, + style: errorStyle, + ), + ], + ], + ), + ), + ); + } +} + class _ProductsListCard extends StatelessWidget { final int? total; final String? error; From 5fbb549229fb24a2aa86786b1cce33caa072a207 Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 7 Jul 2026 11:32:26 +0400 Subject: [PATCH 06/12] fix(push): resolve notification small icon from the host app, not REES46 The plugin displays incoming pushes itself, so it owns the small icon. It was hardcoded to the REES46-branded NotificationResources.NOTIFICATION_ICON, which would brand every client app's notifications with the REES46 logo. Resolve the host's icon instead, mirroring FCM: com.rees46.push.notification_icon meta-data, then the existing Firebase default_notification_icon (reused so hosts don't duplicate config), then the launcher icon, and only as a last resort a neutral non-branded white disc bundled in the plugin. Document the meta-data in the README. --- README.md | 22 +++++++ .../push/Rees46PushNotifier.kt | 64 ++++++++++++++++++- .../res/drawable/ic_rees46_push_default.xml | 17 +++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 android/src/main/res/drawable/ic_rees46_push_default.xml diff --git a/README.md b/README.md index 2183403..f3720d7 100644 --- a/README.md +++ b/README.md @@ -59,5 +59,27 @@ fvm flutter run - **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. +### Android push notification icon + +On Android the plugin displays incoming pushes itself, so the notification's **small icon** is resolved from the **host app**, never from REES46. You should point it at your own icon: + +```xml + + +``` + +The icon must be a **white, alpha-only silhouette** — Android tints the small icon, so a full-colour image renders as a solid white/grey square. Generate one via Android Studio → *New → Image Asset → Notification Icons*. + +**Already using Firebase?** If your app already declares `com.google.firebase.messaging.default_notification_icon`, you don't need to set anything — the plugin reuses that icon. Set `com.rees46.push.notification_icon` only if you want a different icon for REES46 pushes specifically. + +Resolution order: + +1. The `com.rees46.push.notification_icon` meta-data icon above (recommended if you want a dedicated icon). +2. The existing Firebase `com.google.firebase.messaging.default_notification_icon`, if declared. +3. The app's launcher icon, if neither is set (may look like a square, since a launcher icon is not a silhouette). +4. A neutral non-branded default bundled in the plugin, only if the host has no icon at all. + For Flutter plugin development basics, see Flutter docs: [develop plugins](https://flutter.dev/to/develop-plugins). diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt index b5839ba..8e0639c 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt @@ -5,14 +5,15 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat -import com.personalization.resources.NotificationResources import com.personalization.sdk.data.models.dto.notification.NotificationData +import com.rees46.rees46_flutter_sdk.R import java.net.URL /** @@ -37,6 +38,28 @@ object Rees46PushNotifier { const val CHANNEL_ID = "rees46_push" private const val CHANNEL_NAME = "Push notifications" + /** + * Manifest meta-data key a host app can set to point at its own notification icon, e.g.: + * ``` + * + * ``` + */ + private const val META_DATA_ICON = "com.rees46.push.notification_icon" + + /** + * Firebase's own default-notification-icon meta-data. Reused so a host that already configured + * an FCM icon does not have to duplicate it under [META_DATA_ICON]: + * ``` + * + * ``` + */ + private const val FIREBASE_META_DATA_ICON = + "com.google.firebase.messaging.default_notification_icon" + /** Idempotent. Creates the HIGH-importance channel so pushes appear as a heads-up pop-up. */ fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -61,7 +84,7 @@ object Rees46PushNotifier { val largeIcon = data.icon?.trim()?.takeIf { it.isNotEmpty() }?.let(::loadBitmap) val builder = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(NotificationResources.NOTIFICATION_ICON) + .setSmallIcon(resolveSmallIcon(context)) .setContentTitle(data.title) .setContentText(data.body) .setAutoCancel(true) @@ -113,6 +136,43 @@ object Rees46PushNotifier { ) } + /** + * Resolves the small icon so the notification carries the HOST app's branding, never REES46's. + * + * Order mirrors FCM's `default_notification_icon` handling: + * 1. A host-declared icon via [META_DATA_ICON] manifest meta-data. This is the recommended + * path — a small icon must be a white, alpha-only silhouette (Android tints it), which a + * full-colour launcher icon is not. + * 2. The host's existing Firebase [FIREBASE_META_DATA_ICON], so an FCM icon that is already + * configured is reused without the host duplicating it under our key. + * 3. The host app's launcher icon, so the branding is still the client's (not REES46's) even + * when no dedicated icon is configured. + * 4. A neutral, non-branded default ([R.drawable.ic_rees46_push_default], a plain white disc), + * only as a last resort if the host has no icon at all. Never REES46 branding. + */ + private fun resolveSmallIcon(context: Context): Int { + val appInfo = try { + context.packageManager.getApplicationInfo( + context.packageName, + PackageManager.GET_META_DATA, + ) + } catch (e: Exception) { + null + } + val metaData = appInfo?.metaData + + val configured = metaData?.getInt(META_DATA_ICON, 0) ?: 0 + if (configured != 0) return configured + + val firebaseIcon = metaData?.getInt(FIREBASE_META_DATA_ICON, 0) ?: 0 + if (firebaseIcon != 0) return firebaseIcon + + val launcherIcon = appInfo?.icon ?: 0 + if (launcherIcon != 0) return launcherIcon + + return R.drawable.ic_rees46_push_default + } + private fun loadBitmap(url: String): Bitmap? = try { URL(url).openStream().use { BitmapFactory.decodeStream(it) } } catch (e: Exception) { diff --git a/android/src/main/res/drawable/ic_rees46_push_default.xml b/android/src/main/res/drawable/ic_rees46_push_default.xml new file mode 100644 index 0000000..d7138c7 --- /dev/null +++ b/android/src/main/res/drawable/ic_rees46_push_default.xml @@ -0,0 +1,17 @@ + + + + From f98e904903260112815e3468c2c43abbb203bd8b Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 7 Jul 2026 14:57:56 +0400 Subject: [PATCH 07/12] test(integration): de-flake tracking-buttons scroll in init_flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "tracking buttons are visible" test scrolled to the trackEvent/trackPurchase buttons by label text with the default 15 scrolls. Those buttons sit at the very bottom of a long form: each timedDrag relies on fling momentum to cover distance, and on a GPU-throttled CI emulator (Failed to find ColorBuffer) that momentum shrinks, so 15 scrolls can undershoot and the finder never becomes hit-testable. Scroll by widget key with maxScrolls raised to 40 so the buttons are reached even when per-drag distance is reduced. No app or SDK change — flaky test only. --- example/integration_test/init_flow_test.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/example/integration_test/init_flow_test.dart b/example/integration_test/init_flow_test.dart index 6cda391..70add30 100644 --- a/example/integration_test/init_flow_test.dart +++ b/example/integration_test/init_flow_test.dart @@ -24,8 +24,11 @@ void main() { await $( 'Status: Initialized', ).waitUntilVisible(timeout: const Duration(seconds: 30)); - await $('Send demo trackEvent').scrollTo(); - await $('Send demo trackPurchase').scrollTo(); + // Scroll by key (not label text), with extra drags: these buttons sit at the + // very bottom of a long form, and on a GPU-throttled CI emulator the per-drag + // fling momentum shrinks, so the default 15 scrolls can undershoot. + await $(const Key('example_demo_track_event')).scrollTo(maxScrolls: 40); + await $(const Key('example_demo_track_purchase')).scrollTo(maxScrolls: 40); }); patrolTest( From b187359e7c9e7db15670950a2890cd5775171e05 Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 7 Jul 2026 15:00:20 +0400 Subject: [PATCH 08/12] style: apply dart format across lib, tests and generated pigeon The CI `dart format --set-exit-if-changed .` check flagged 10 files (response models, personalization_sdk, two integration tests, catalog_test and the generated pigeon API) as unformatted. Run `dart format .` to bring them in line. No behavioural change. --- .../recommendation_sdk_test.dart | 92 +-- .../search_blank_sdk_test.dart | 24 +- lib/src/category/category_response.dart | 5 +- lib/src/personalization_sdk.dart | 4 +- lib/src/pigeon/personalization_api.g.dart | 534 +++++++++++------- .../products/product_counters_response.dart | 1 + lib/src/products/products_list_response.dart | 3 +- .../recommendation_response.dart | 4 +- lib/src/search/search_response.dart | 4 +- test/catalog_test.dart | 7 +- 10 files changed, 431 insertions(+), 247 deletions(-) diff --git a/example/integration_test/recommendation_sdk_test.dart b/example/integration_test/recommendation_sdk_test.dart index c195a4a..e5c7539 100644 --- a/example/integration_test/recommendation_sdk_test.dart +++ b/example/integration_test/recommendation_sdk_test.dart @@ -26,48 +26,56 @@ 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:')); - // Skipped until a real recommender block code for the test shop is set in - // TestConfig — the placeholder returns 404. Auto-runs once a code is set. - }, skip: TestConfig.recommendationBlockCode == 'your_block_code'); - - 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)); - }, skip: TestConfig.recommendationBlockCode == 'your_block_code'); + 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:')); + // Skipped until a real recommender block code for the test shop is set in + // TestConfig — the placeholder returns 404. Auto-runs once a code is set. + }, + skip: TestConfig.recommendationBlockCode == 'your_block_code', + ); + + 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)); + }, + skip: TestConfig.recommendationBlockCode == 'your_block_code', + ); patrolTest('getRecommendation — empty block code does not crash', ($) async { await _initializeSdk($); diff --git a/example/integration_test/search_blank_sdk_test.dart b/example/integration_test/search_blank_sdk_test.dart index f389466..95173f6 100644 --- a/example/integration_test/search_blank_sdk_test.dart +++ b/example/integration_test/search_blank_sdk_test.dart @@ -26,7 +26,11 @@ void main() { await $(const Key('btn_search_blank')).scrollTo(); await $(const Key('btn_search_blank')).tap(); - await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); + await waitForResultOrError( + $, + 'lbl_search_blank_products', + 'lbl_search_blank_error', + ); expect(find.byKey(const Key('lbl_search_blank_error')), findsNothing); @@ -49,12 +53,20 @@ void main() { await $(const Key('btn_search_blank')).scrollTo(); await $(const Key('btn_search_blank')).tap(); - await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); + await waitForResultOrError( + $, + 'lbl_search_blank_products', + 'lbl_search_blank_error', + ); final firstProducts = _labelText($, 'lbl_search_blank_products'); await $(const Key('btn_search_blank')).scrollTo(); await $(const Key('btn_search_blank')).tap(); - await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); + await waitForResultOrError( + $, + 'lbl_search_blank_products', + 'lbl_search_blank_error', + ); final secondProducts = _labelText($, 'lbl_search_blank_products'); expect(firstProducts, equals(secondProducts)); @@ -65,7 +77,11 @@ void main() { await $(const Key('btn_search_blank')).scrollTo(); await $(const Key('btn_search_blank')).tap(); - await waitForResultOrError($, 'lbl_search_blank_products', 'lbl_search_blank_error'); + await waitForResultOrError( + $, + 'lbl_search_blank_products', + 'lbl_search_blank_error', + ); expect(find.byKey(const Key('lbl_search_blank_error')), findsNothing); expect(find.byKey(const Key('lbl_search_blank_products')), findsOneWidget); diff --git a/lib/src/category/category_response.dart b/lib/src/category/category_response.dart index d80404f..356e463 100644 --- a/lib/src/category/category_response.dart +++ b/lib/src/category/category_response.dart @@ -13,10 +13,7 @@ class CategoryResponse { final int productsTotal; final List products; - const CategoryResponse({ - required this.productsTotal, - required this.products, - }); + const CategoryResponse({required this.productsTotal, required this.products}); factory CategoryResponse.fromJson(Map json) { return CategoryResponse( diff --git a/lib/src/personalization_sdk.dart b/lib/src/personalization_sdk.dart index eb2107d..402ddca 100644 --- a/lib/src/personalization_sdk.dart +++ b/lib/src/personalization_sdk.dart @@ -192,7 +192,9 @@ class PersonalizationSdk { ); } final json = await _api.getCollection(collectionId); - return CollectionResponse.fromJson(jsonDecode(json) as Map); + return CollectionResponse.fromJson( + jsonDecode(json) as Map, + ); } Future getRecommendation( diff --git a/lib/src/pigeon/personalization_api.g.dart b/lib/src/pigeon/personalization_api.g.dart index 5a90972..6d994a0 100644 --- a/lib/src/pigeon/personalization_api.g.dart +++ b/lib/src/pigeon/personalization_api.g.dart @@ -15,7 +15,11 @@ PlatformException _createConnectionError(String channelName) { ); } -List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { +List wrapResponse({ + Object? result, + PlatformException? error, + bool empty = false, +}) { if (empty) { return []; } @@ -24,21 +28,25 @@ List wrapResponse({Object? result, PlatformException? error, bool empty } 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])); + 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.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, @@ -81,7 +89,8 @@ class InitConfig { } Object encode() { - return _toList(); } + return _toList(); + } static InitConfig decode(Object result) { result as List; @@ -111,8 +120,7 @@ class InitConfig { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } /// Wire format for one purchase line (maps to native `PurchaseItemRequest`). @@ -139,17 +147,12 @@ class PurchaseLineItemWire { String? fashionSize; List _toList() { - return [ - id, - amount, - price, - lineId, - fashionSize, - ]; + return [id, amount, price, lineId, fashionSize]; } Object encode() { - return _toList(); } + return _toList(); + } static PurchaseLineItemWire decode(Object result) { result as List; @@ -176,8 +179,7 @@ class PurchaseLineItemWire { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } /// Wire format for profile fields sent to native SDK. @@ -275,7 +277,8 @@ class ProfileParamsWire { } Object encode() { - return _toList(); } + return _toList(); + } static ProfileParamsWire decode(Object result) { result as List; @@ -317,11 +320,9 @@ class ProfileParamsWire { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } - class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -329,13 +330,13 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is InitConfig) { + } else if (value is InitConfig) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is PurchaseLineItemWire) { + } else if (value is PurchaseLineItemWire) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is ProfileParamsWire) { + } else if (value is ProfileParamsWire) { buffer.putUint8(131); writeValue(buffer, value.encode()); } else { @@ -346,11 +347,11 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return InitConfig.decode(readValue(buffer)!); - case 130: + case 130: return PurchaseLineItemWire.decode(readValue(buffer)!); - case 131: + case 131: return ProfileParamsWire.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -362,9 +363,13 @@ 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' : ''; + PersonalizationHostApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -372,13 +377,17 @@ class PersonalizationHostApi { 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 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 Future pigeonVar_sendFuture = pigeonVar_channel.send([config]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -395,12 +404,14 @@ class PersonalizationHostApi { } 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 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?; @@ -424,12 +435,14 @@ class PersonalizationHostApi { /// 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 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?; @@ -447,14 +460,25 @@ class PersonalizationHostApi { } /// [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, + 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 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) { @@ -471,13 +495,17 @@ class PersonalizationHostApi { } 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 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 Future pigeonVar_sendFuture = pigeonVar_channel.send([params]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -497,13 +525,17 @@ class PersonalizationHostApi { /// [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 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 Future pigeonVar_sendFuture = pigeonVar_channel.send([code, paramsJson]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -526,12 +558,14 @@ class PersonalizationHostApi { /// 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 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?; @@ -555,12 +589,14 @@ class PersonalizationHostApi { /// 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 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?; @@ -580,13 +616,17 @@ class PersonalizationHostApi { /// 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 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 Future pigeonVar_sendFuture = pigeonVar_channel.send([itemId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -611,13 +651,17 @@ class PersonalizationHostApi { /// [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 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 Future pigeonVar_sendFuture = pigeonVar_channel.send([paramsJson]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -642,12 +686,14 @@ class PersonalizationHostApi { /// 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 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?; @@ -673,13 +719,17 @@ class PersonalizationHostApi { /// [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 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 Future pigeonVar_sendFuture = pigeonVar_channel.send([query, paramsJson]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -704,13 +754,17 @@ class PersonalizationHostApi { /// [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 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 Future pigeonVar_sendFuture = pigeonVar_channel.send([query, paramsJson]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -735,14 +789,23 @@ class PersonalizationHostApi { /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. /// Dart layer parses the result into [LoyaltyJoinResponse]. - Future joinLoyalty(String phone, String? email, String? firstName, String? lastName) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, + Future joinLoyalty( + String phone, + String? email, + String? firstName, + String? lastName, + ) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [phone, email, firstName, lastName], ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([phone, email, firstName, lastName]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -768,13 +831,17 @@ class PersonalizationHostApi { /// [identifier] is the member identifier (phone). /// Dart layer parses the result into [LoyaltyStatusResponse]. Future getLoyaltyStatus(String identifier) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [identifier], ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([identifier]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -798,12 +865,14 @@ class PersonalizationHostApi { /// Returns the current user's profile as a JSON string. /// Dart layer parses the result into [ProfileResponse]. Future getProfile() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile$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?; @@ -828,13 +897,17 @@ class PersonalizationHostApi { /// Returns view / cart / purchase counters for [item] as a JSON string. /// Dart layer parses the result into [ProductCountersResponse]. Future getProductCounters(String item) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductCounters$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductCounters$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [item], ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([item]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -859,13 +932,17 @@ class PersonalizationHostApi { /// [limit] and [page] paginate the result; both are optional. /// Dart layer parses the result into [CategoryResponse]. Future getCategory(String category, int? limit, int? page) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCategory$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCategory$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [category, limit, page], ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([category, limit, page]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -889,13 +966,17 @@ class PersonalizationHostApi { /// Returns a merchandised collection's products as a JSON string. /// Dart layer parses the result into [CollectionResponse]. Future getCollection(String collectionId) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCollection$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, + final String pigeonVar_channelName = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCollection$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [collectionId], ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([collectionId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -917,14 +998,53 @@ class PersonalizationHostApi { } /// [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]); + 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) { @@ -950,79 +1070,115 @@ abstract class PersonalizationFlutterApi { void onPushClicked(Map payload); - static void setUp(PersonalizationFlutterApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { - messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + 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); + 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.'); + 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.'); + 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())); + } 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); + 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.'); + 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.'); + 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())); + } 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); + 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.'); + 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.'); + 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())); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); } }); } diff --git a/lib/src/products/product_counters_response.dart b/lib/src/products/product_counters_response.dart index f93a741..9f1f2f7 100644 --- a/lib/src/products/product_counters_response.dart +++ b/lib/src/products/product_counters_response.dart @@ -1,4 +1,5 @@ import '../json_number.dart'; + /// Response from [PersonalizationSdk.getProductCounters]. /// /// Mirrors native `ProductCountersResponse`: per-period view/cart/purchase diff --git a/lib/src/products/products_list_response.dart b/lib/src/products/products_list_response.dart index 9fb1b2a..8b17500 100644 --- a/lib/src/products/products_list_response.dart +++ b/lib/src/products/products_list_response.dart @@ -93,8 +93,7 @@ class Product { priceFullFormatted: json['price_full_formatted'] as String?, currency: json['currency'] as String? ?? '', salesRate: toIntOrNull(json['sales_rate']) ?? 0, - relativeSalesRate: - toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, + relativeSalesRate: toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, resizedImages: _parseResizedImages(json['image_url_resized']), categories: (json['categories'] as List? ?? []) .map((e) => ProductCategory.fromJson(e as Map)) diff --git a/lib/src/recommendation/recommendation_response.dart b/lib/src/recommendation/recommendation_response.dart index 6398c58..79681c8 100644 --- a/lib/src/recommendation/recommendation_response.dart +++ b/lib/src/recommendation/recommendation_response.dart @@ -1,4 +1,5 @@ import '../json_number.dart'; + /// Typed response for [PersonalizationSdk.getRecommendation]. /// /// Contains only fields present in **both** Android and iOS native SDK models. @@ -111,8 +112,7 @@ class RecommendedProduct { priceFullFormatted: json['price_full_formatted'] as String?, currency: json['currency'] as String? ?? '', salesRate: toIntOrNull(json['sales_rate']) ?? 0, - relativeSalesRate: - toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, + relativeSalesRate: toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, ); } } diff --git a/lib/src/search/search_response.dart b/lib/src/search/search_response.dart index ee64d36..652dbb2 100644 --- a/lib/src/search/search_response.dart +++ b/lib/src/search/search_response.dart @@ -1,4 +1,5 @@ import '../json_number.dart'; + /// Response from [PersonalizationSdk.searchFull]. /// /// Excluded Android-only response fields: brands (`List`), clarification, @@ -97,8 +98,7 @@ class SearchProduct { priceFullFormatted: json['price_full_formatted'] as String?, currency: json['currency'] as String? ?? '', salesRate: toIntOrNull(json['sales_rate']) ?? 0, - relativeSalesRate: - toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, + relativeSalesRate: toDoubleOrNull(json['relative_sales_rate']) ?? 0.0, resizedImages: _parseResizedImages(json['image_url_resized']), ); } diff --git a/test/catalog_test.dart b/test/catalog_test.dart index 53f7693..eda4052 100644 --- a/test/catalog_test.dart +++ b/test/catalog_test.dart @@ -98,7 +98,12 @@ void main() { }); test('absent sections parse to null', () async { - stub(_countersChannel, jsonEncode({'now': {'view': 1, 'cart': 0, 'purchase': 0}})); + stub( + _countersChannel, + jsonEncode({ + 'now': {'view': 1, 'cart': 0, 'purchase': 0}, + }), + ); final r = await PersonalizationSdk().getProductCounters('item1'); expect(r.now?.view, equals(1)); From ad3a494c372280b7e9f9b56fd6b3bd2f5da4bfb0 Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 7 Jul 2026 15:11:55 +0400 Subject: [PATCH 09/12] ci: exclude generated *.g.dart from analyze and format checks Generated Pigeon files (lib/src/pigeon/*.g.dart) are not hand-edited, so a regen should not fail CI. Exclude them from `flutter analyze` via analysis_options `analyzer: exclude`. `dart format` does not honour that list, so the formatting step filters them out on the command line with a git pathspec instead. --- .github/workflows/checks.yaml | 5 ++++- analysis_options.yaml | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index c9c43cb..1e9a132 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -49,8 +49,11 @@ jobs: - name: Run tests run: flutter test --coverage + # Exclude generated *.g.dart (Pigeon): they are not hand-formatted, and + # `dart format` ignores the analysis_options `formatter: exclude` list, so + # filter them out here via git pathspec. - name: Check formatting - run: dart format --output=none --set-exit-if-changed . + run: dart format --output=none --set-exit-if-changed $(git ls-files '*.dart' ':!:*.g.dart') # This is a plugin package: the buildable app lives in example/, # so build there (the repo root has no lib/main.dart). diff --git a/analysis_options.yaml b/analysis_options.yaml index a5744c1..0b41097 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,11 @@ include: package:flutter_lints/flutter.yaml +# Generated files (e.g. Pigeon *.g.dart) are not hand-edited, so exclude them from +# analysis. NOTE: `dart format` does NOT honour this list, so the CI formatting +# check filters *.g.dart out on the command line instead (see .github/workflows). +analyzer: + exclude: + - "**/*.g.dart" + # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options From d875b817fdc8f33847cf1ade7a5780ee50d035ae Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 7 Jul 2026 15:29:11 +0400 Subject: [PATCH 10/12] test(integration): raise scroll headroom for bottom-section targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit patrol_smoke_test failed on the CI emulator at scrollTo("Tracking") — the same GPU-stall fling-undershoot fixed in init_flow: the demo form's bottom sections (Loyalty, Catalog, Tracking) sit far down, and the default 15 fling-drags can fall short while the emulator's ColorBuffer errors throttle rendering. Bump maxScrolls to 40 on every scroll that targets those three sections (Loyalty status/join, all four Catalog buttons, the Tracking header and its two buttons). Upper/mid sections scroll reliably and are left unchanged. Test-only. --- example/integration_test/catalog_sdk_test.dart | 12 ++++++------ example/integration_test/loyalty_sdk_test.dart | 4 ++-- example/integration_test/patrol_smoke_test.dart | 9 ++++++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/example/integration_test/catalog_sdk_test.dart b/example/integration_test/catalog_sdk_test.dart index dda4272..3a839e9 100644 --- a/example/integration_test/catalog_sdk_test.dart +++ b/example/integration_test/catalog_sdk_test.dart @@ -45,7 +45,7 @@ void main() { patrolTest('getProfile — returns the session profile', ($) async { await _initializeSdk($); - await $(const Key('btn_catalog_profile')).scrollTo(); + await $(const Key('btn_catalog_profile')).scrollTo(maxScrolls: 40); await $(const Key('btn_catalog_profile')).tap(); await $( @@ -68,7 +68,7 @@ void main() { ); await $.tester.pump(); - await $(const Key('btn_catalog_counters')).scrollTo(); + await $(const Key('btn_catalog_counters')).scrollTo(maxScrolls: 40); await $(const Key('btn_catalog_counters')).tap(); await $( @@ -89,7 +89,7 @@ void main() { ); await $.tester.pump(); - await $(const Key('btn_catalog_counters')).scrollTo(); + await $(const Key('btn_catalog_counters')).scrollTo(maxScrolls: 40); await $(const Key('btn_catalog_counters')).tap(); await $.pumpAndSettle(); @@ -109,7 +109,7 @@ void main() { ); await $.tester.pump(); - await $(const Key('btn_catalog_category')).scrollTo(); + await $(const Key('btn_catalog_category')).scrollTo(maxScrolls: 40); await $(const Key('btn_catalog_category')).tap(); await _waitForTerminalState( @@ -134,7 +134,7 @@ void main() { ); await $.tester.pump(); - await $(const Key('btn_catalog_category')).scrollTo(); + await $(const Key('btn_catalog_category')).scrollTo(maxScrolls: 40); await $(const Key('btn_catalog_category')).tap(); await $.pumpAndSettle(); @@ -154,7 +154,7 @@ void main() { ); await $.tester.pump(); - await $(const Key('btn_catalog_collection')).scrollTo(); + await $(const Key('btn_catalog_collection')).scrollTo(maxScrolls: 40); await $(const Key('btn_catalog_collection')).tap(); await _waitForTerminalState( diff --git a/example/integration_test/loyalty_sdk_test.dart b/example/integration_test/loyalty_sdk_test.dart index 3f66ec5..4d07372 100644 --- a/example/integration_test/loyalty_sdk_test.dart +++ b/example/integration_test/loyalty_sdk_test.dart @@ -24,7 +24,7 @@ void main() { patrolTest('getLoyaltyStatus — real call returns success', ($) async { await _initializeSdk($); - await $(const Key('btn_loyalty_status')).scrollTo(); + await $(const Key('btn_loyalty_status')).scrollTo(maxScrolls: 40); await $(const Key('btn_loyalty_status')).tap(); await $( @@ -41,7 +41,7 @@ void main() { patrolTest('joinLoyalty — real call returns success', ($) async { await _initializeSdk($); - await $(const Key('btn_join_loyalty')).scrollTo(); + await $(const Key('btn_join_loyalty')).scrollTo(maxScrolls: 40); await $(const Key('btn_join_loyalty')).tap(); await $( diff --git a/example/integration_test/patrol_smoke_test.dart b/example/integration_test/patrol_smoke_test.dart index 75c20f8..935ae6e 100644 --- a/example/integration_test/patrol_smoke_test.dart +++ b/example/integration_test/patrol_smoke_test.dart @@ -12,9 +12,12 @@ void main() { 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(); + // Tracking is the last section of a long form. Each scroll relies on fling + // momentum that a GPU-throttled CI emulator shrinks, so the default 15 scrolls + // can undershoot; give generous headroom (the loop stops once it's visible). + await $('Tracking').scrollTo(maxScrolls: 40); + await $('Send demo trackEvent').scrollTo(maxScrolls: 40); + await $('Send demo trackPurchase').scrollTo(maxScrolls: 40); }); patrolTest( From 70d1d1bc85eceacef2b37a54d0b7bb1d16feb13f Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 7 Jul 2026 16:30:47 +0400 Subject: [PATCH 11/12] test(integration): wait for async result, not the button, in profile/catalog reads getProductInfo, setProfile and getRecommendation flaked on the CI emulator: they read the result label right after waiting on the tapped button (which never leaves the tree) or after pumpAndSettle (which returns before a network call completes), so on a slow/GPU-throttled run the assertion ran before the result rendered. Wait for the outcome instead: - product_info, products_list, recommendation, setProfile: result/error labels render only on completion -> waitForResultOrError / waitUntilExists. - getSid/getDid: the label is always present (placeholder '-') -> new pumpUntil helper waits for its text to change. products_list carried the same latent race (it passed by luck this run) and is fixed too. Test-only, no app/SDK change. --- example/integration_test/patrol_setup.dart | 19 +++++++++++++ .../product_info_sdk_test.dart | 12 ++++++-- .../products_list_sdk_test.dart | 24 +++++++++++++--- .../integration_test/profile_sdk_test.dart | 28 ++++++++++++------- .../recommendation_sdk_test.dart | 9 +++--- 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/example/integration_test/patrol_setup.dart b/example/integration_test/patrol_setup.dart index d0acde5..b77ea04 100644 --- a/example/integration_test/patrol_setup.dart +++ b/example/integration_test/patrol_setup.dart @@ -24,6 +24,25 @@ Future waitForResultOrError( } } +/// Pumps (up to [maxPumps] × 500 ms) until [predicate] returns true. +/// +/// Use this to wait for an async SDK result to render. `pumpAndSettle` returns +/// before a network call completes (nothing keeps scheduling frames while the +/// future is in flight), and waiting on the tapped button is a no-op because the +/// button never left the tree — both race the result. Prefer [waitForResultOrError] +/// when the result/error widgets only appear on completion; use this when a label +/// is always present and only its text changes (e.g. a value replacing a '—'). +Future pumpUntil( + PatrolIntegrationTester $, + bool Function() predicate, { + int maxPumps = 60, +}) async { + for (var i = 0; i < maxPumps; i++) { + if (predicate()) return; + await $.tester.pump(const Duration(milliseconds: 500)); + } +} + /// Dismisses the Android 13+ POST_NOTIFICATIONS permission dialog that the /// example app requests once at startup (see MainActivity). /// diff --git a/example/integration_test/product_info_sdk_test.dart b/example/integration_test/product_info_sdk_test.dart index eba0445..72ad4c8 100644 --- a/example/integration_test/product_info_sdk_test.dart +++ b/example/integration_test/product_info_sdk_test.dart @@ -34,7 +34,11 @@ void main() { await $('Get Product Info').scrollTo(); await $('Get Product Info').tap(); - await $('Get Product Info').waitUntilVisible(); + await waitForResultOrError( + $, + 'lbl_product_info_name', + 'lbl_product_info_error', + ); expect(find.byKey(const Key('lbl_product_info_error')), findsNothing); @@ -69,7 +73,11 @@ void main() { await $('Get Product Info').scrollTo(); await $('Get Product Info').tap(); - await $('Get Product Info').waitUntilVisible(); + await waitForResultOrError( + $, + 'lbl_product_info_name', + 'lbl_product_info_error', + ); final hasName = find .byKey(const Key('lbl_product_info_name')) diff --git a/example/integration_test/products_list_sdk_test.dart b/example/integration_test/products_list_sdk_test.dart index 2b1f3d1..e29fc47 100644 --- a/example/integration_test/products_list_sdk_test.dart +++ b/example/integration_test/products_list_sdk_test.dart @@ -26,7 +26,11 @@ void main() { await $('Get Products List').scrollTo(); await $('Get Products List').tap(); - await $('Get Products List').waitUntilVisible(); + await waitForResultOrError( + $, + 'lbl_products_list_total', + 'lbl_products_list_error', + ); expect(find.byKey(const Key('lbl_products_list_error')), findsNothing); @@ -42,12 +46,20 @@ void main() { await $('Get Products List').scrollTo(); await $('Get Products List').tap(); - await $('Get Products List').waitUntilVisible(); + await waitForResultOrError( + $, + 'lbl_products_list_total', + 'lbl_products_list_error', + ); final first = _labelText($, 'lbl_products_list_total'); await $('Get Products List').scrollTo(); await $('Get Products List').tap(); - await $('Get Products List').waitUntilVisible(); + await waitForResultOrError( + $, + 'lbl_products_list_total', + 'lbl_products_list_error', + ); final second = _labelText($, 'lbl_products_list_total'); expect(first, equals(second)); @@ -58,7 +70,11 @@ void main() { await $('Get Products List').scrollTo(); await $('Get Products List').tap(); - await $('Get Products List').waitUntilVisible(); + await waitForResultOrError( + $, + 'lbl_products_list_total', + 'lbl_products_list_error', + ); 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 index 4ee2229..4a82dd1 100644 --- a/example/integration_test/profile_sdk_test.dart +++ b/example/integration_test/profile_sdk_test.dart @@ -29,7 +29,9 @@ void main() { await $('Get SID').scrollTo(); await $('Get SID').tap(); - await $.pumpAndSettle(); + // lbl_sid is always present (shows '—' before the call); wait for the value, + // not pumpAndSettle which returns before the async call has resolved. + await pumpUntil($, () => _labelText($, 'lbl_sid') != '—'); final sid = _labelText($, 'lbl_sid'); expect(sid, isNotEmpty); @@ -42,12 +44,12 @@ void main() { await $('Get SID').scrollTo(); await $('Get SID').tap(); - await $.pumpAndSettle(); + await pumpUntil($, () => _labelText($, 'lbl_sid') != '—'); final first = _labelText($, 'lbl_sid'); await $('Get SID').scrollTo(); await $('Get SID').tap(); - await $.pumpAndSettle(); + await pumpUntil($, () => _labelText($, 'lbl_sid') != '—'); final second = _labelText($, 'lbl_sid'); expect(first, equals(second)); @@ -61,7 +63,7 @@ void main() { await $('Get DID').scrollTo(); await $('Get DID').tap(); - await $.pumpAndSettle(); + await pumpUntil($, () => _labelText($, 'lbl_did') != '—'); final did = _labelText($, 'lbl_did'); expect(did, isNot(contains('Error'))); @@ -74,7 +76,7 @@ void main() { await $('Get DID').scrollTo(); await $('Get DID').tap(); - await $.pumpAndSettle(); + await pumpUntil($, () => _labelText($, 'lbl_did') != '—'); final did = _labelText($, 'lbl_did'); // Either the SDK assigned a DID or returned null before the first API sync. @@ -89,10 +91,12 @@ void main() { await $('Set Profile').scrollTo(); await $('Set Profile').tap(); - await $.pumpAndSettle(); + // lbl_profile_status is rendered only once the call completes. + await $( + const Key('lbl_profile_status'), + ).waitUntilExists(timeout: const Duration(seconds: 30)); - final status = _labelText($, 'lbl_profile_status'); - expect(status, equals('Profile set')); + expect(_labelText($, 'lbl_profile_status'), equals('Profile set')); }); patrolTest('setProfile — can be called multiple times', ($) async { @@ -100,12 +104,16 @@ void main() { await $('Set Profile').scrollTo(); await $('Set Profile').tap(); - await $.pumpAndSettle(); + await $( + const Key('lbl_profile_status'), + ).waitUntilExists(timeout: const Duration(seconds: 30)); expect(_labelText($, 'lbl_profile_status'), equals('Profile set')); await $('Set Profile').scrollTo(); await $('Set Profile').tap(); - await $.pumpAndSettle(); + await $( + const Key('lbl_profile_status'), + ).waitUntilExists(timeout: const Duration(seconds: 30)); expect(_labelText($, 'lbl_profile_status'), equals('Profile set')); }); } diff --git a/example/integration_test/recommendation_sdk_test.dart b/example/integration_test/recommendation_sdk_test.dart index e5c7539..82bab91 100644 --- a/example/integration_test/recommendation_sdk_test.dart +++ b/example/integration_test/recommendation_sdk_test.dart @@ -40,8 +40,9 @@ void main() { await $('Get Recommendations').scrollTo(); await $('Get Recommendations').tap(); - // Wait for loading to finish — button text reverts to 'Get Recommendations'. - await $('Get Recommendations').waitUntilVisible(); + // Wait for the recommendation result (or error) to render — not just the + // button, which never left the tree, so the async call may still be pending. + await waitForResultOrError($, 'lbl_rec_title', 'lbl_rec_error'); expect(find.byKey(const Key('lbl_rec_error')), findsNothing); @@ -66,7 +67,7 @@ void main() { await $.tester.pump(); await $('Get Recommendations').scrollTo(); await $('Get Recommendations').tap(); - await $('Get Recommendations').waitUntilVisible(); + await waitForResultOrError($, 'lbl_rec_title', 'lbl_rec_error'); final countText = _labelText($, 'lbl_rec_count'); // Text is "Products: N" — extract the number. @@ -102,7 +103,7 @@ void main() { await $.tester.pump(); await $('Get Recommendations').scrollTo(); await $('Get Recommendations').tap(); - await $('Get Recommendations').waitUntilVisible(); + await waitForResultOrError($, 'lbl_rec_title', 'lbl_rec_error'); // Either an error label appears, or we get an empty product list — both are valid. final hasError = find From a82b5a698a5dc716902c3ab28a12eb433f5ca7d7 Mon Sep 17 00:00:00 2001 From: andreykropotov Date: Tue, 7 Jul 2026 19:24:39 +0400 Subject: [PATCH 12/12] ci(android): boot emulator from cached AVD snapshot on google_apis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Patrol job intermittently hung mid-suite: the GitHub-hosted emulator froze during the per-test cold restarts (adb offline / dead device). This is a known limitation of GHA-hosted emulators (the Patrol maintainers advise against them), not a test bug — the suite reaches 42-45/45 when the device stays alive. Apply the reactivecircus-recommended reliability recipe: - Cache an AVD snapshot (warm-up run creates it, test run restores it with -no-snapshot-save) instead of cold-booting every run — cold boot is the phase the runs hung in. Drops -no-snapshot, which had disabled snapshotting entirely. - Switch the system image from AOSP `default` to `google_apis`, which ships Google Play services (needed for the FCM token fetch on SDK init) and is less flaky on recent API levels. - Raise timeout-minutes 60 -> 90 as headroom for the snapshot warm-up. If this still flakes, the next step is emulator.wtf / Firebase Test Lab per the Patrol team's own recommendation. --- .github/workflows/integration-android.yaml | 47 +++++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration-android.yaml b/.github/workflows/integration-android.yaml index 1461e4e..db0ac1d 100644 --- a/.github/workflows/integration-android.yaml +++ b/.github/workflows/integration-android.yaml @@ -16,7 +16,7 @@ jobs: patrol-android: if: github.event.pull_request.draft == false runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 @@ -48,21 +48,56 @@ jobs: dart pub global activate patrol_cli 4.3.1 echo "$HOME/.pub-cache/bin" >> "$GITHUB_PATH" - # Boots an emulator, runs the whole integration_test/ suite on it, tears down. - # The tests drive the real example app against the REES46 demo shop (c1140c…), - # so they exercise the Kotlin bridge + real backend — what unit tests can't. + # GitHub-hosted emulators cold-boot flakily (adb offline / frozen device), + # which is the phase our runs hung in. Boot from a cached AVD snapshot + # instead: a warm-up run creates it once, and test runs restore it with + # `-no-snapshot-save`. The image is `google_apis` (ships Google Play + # services, needed for the FCM token fetch on init) — the AOSP `default` + # image lacks it and is markedly flakier. All AVD params must match between + # the warm-up and the test run, and are encoded in the cache key. + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-api34-google_apis-x86_64-pixel6-v1 + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + ram-size: 4096M + cores: 3 + force-avd-creation: false + emulator-options: >- + -no-window -noaudio -no-boot-anim + -gpu swiftshader_indirect -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + # Boots the emulator from the cached snapshot and runs the whole + # integration_test/ suite. The tests drive the real example app against the + # REES46 demo shop (c1140c…), exercising the Kotlin bridge + real backend. - name: Run Patrol integration tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 - target: default + target: google_apis arch: x86_64 profile: pixel_6 ram-size: 4096M cores: 3 + force-avd-creation: false emulator-options: >- - -no-window -no-snapshot -noaudio -no-boot-anim + -no-snapshot-save -no-window -noaudio -no-boot-anim -gpu swiftshader_indirect -camera-back none + disable-animations: true working-directory: example script: | flutter pub get