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/.github/workflows/integration-android.yaml b/.github/workflows/integration-android.yaml new file mode 100644 index 0000000..db0ac1d --- /dev/null +++ b/.github/workflows/integration-android.yaml @@ -0,0 +1,104 @@ +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: 90 + + 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" + + # 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: google_apis + arch: x86_64 + profile: pixel_6 + ram-size: 4096M + cores: 3 + force-avd-creation: false + emulator-options: >- + -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 + patrol test --verbose 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/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/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 diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 8aff7c9..a922ab9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -84,14 +84,18 @@ 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", ) + // 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 30c52f6..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) { _ -> } } } } @@ -354,6 +352,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/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..8e0639c --- /dev/null +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushNotifier.kt @@ -0,0 +1,181 @@ +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.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.sdk.data.models.dto.notification.NotificationData +import com.rees46.rees46_flutter_sdk.R +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" + + /** + * 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) { + 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(resolveSmallIcon(context)) + .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, + ) + } + + /** + * 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) { + null + } +} 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 @@ + + + + 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..3a839e9 --- /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(maxScrolls: 40); + 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(maxScrolls: 40); + 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(maxScrolls: 40); + 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(maxScrolls: 40); + 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(maxScrolls: 40); + 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(maxScrolls: 40); + 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..70add30 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,18 +19,23 @@ void main() { $, ) async { await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); 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( '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..4d07372 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)); @@ -21,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 $( @@ -38,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_setup.dart b/example/integration_test/patrol_setup.dart new file mode 100644 index 0000000..b77ea04 --- /dev/null +++ b/example/integration_test/patrol_setup.dart @@ -0,0 +1,70 @@ +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)); + } +} + +/// 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). +/// +/// 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..935ae6e 100644 --- a/example/integration_test/patrol_smoke_test.dart +++ b/example/integration_test/patrol_smoke_test.dart @@ -2,22 +2,29 @@ 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(); 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( '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..72ad4c8 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)); @@ -31,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); @@ -43,6 +50,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(); @@ -62,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 df095cd..e29fc47 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)); @@ -23,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); @@ -39,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)); @@ -55,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 8d02394..4a82dd1 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)); @@ -26,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); @@ -39,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)); @@ -58,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'))); @@ -71,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. @@ -86,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 { @@ -97,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/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..82bab91 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)); @@ -23,51 +26,64 @@ void main() { // --------------------------------------------------------------------------- // getRecommendation // --------------------------------------------------------------------------- - patrolTest('getRecommendation — returns title and product list', ($) async { - await _initializeSdk($); - - await $.tester.enterText( - find.byKey(const Key('field_rec_block_code')), - TestConfig.recommendationBlockCode, - ); - await $.tester.pump(); - - await $('Get Recommendations').scrollTo(); - await $('Get Recommendations').tap(); - - // Wait for loading to finish — button text reverts to 'Get Recommendations'. - await $('Get Recommendations').waitUntilVisible(); - - expect(find.byKey(const Key('lbl_rec_error')), findsNothing); - - final title = _labelText($, 'lbl_rec_title'); - expect(title, isNot(contains('Error'))); - expect(title, startsWith('Title:')); - }); - - patrolTest('getRecommendation — product count is non-negative', ($) async { - await _initializeSdk($); - - await $.tester.enterText( - find.byKey(const Key('field_rec_block_code')), - TestConfig.recommendationBlockCode, - ); - await $.tester.pump(); - await $('Get Recommendations').scrollTo(); - await $('Get Recommendations').tap(); - await $('Get Recommendations').waitUntilVisible(); - - final countText = _labelText($, 'lbl_rec_count'); - // Text is "Products: N" — extract the number. - final n = int.tryParse(countText.replaceFirst('Products: ', '')); - expect(n, isNotNull); - expect(n, greaterThanOrEqualTo(0)); - }); + patrolTest( + 'getRecommendation — 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 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); + + 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 waitForResultOrError($, 'lbl_rec_title', 'lbl_rec_error'); + + 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($); - // 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(); @@ -87,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 diff --git a/example/integration_test/search_blank_sdk_test.dart b/example/integration_test/search_blank_sdk_test.dart index 4b4af85..95173f6 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,13 @@ 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 +51,22 @@ 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 +75,13 @@ 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; 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..356e463 --- /dev/null +++ b/lib/src/category/category_response.dart @@ -0,0 +1,26 @@ +import '../products/products_list_response.dart' show Product; +import '../json_number.dart'; + +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: toIntOrNull(json['products_total']) ?? 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/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/personalization_sdk.dart b/lib/src/personalization_sdk.dart index 8bb5071..402ddca 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,55 @@ 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..6d994a0 100644 --- a/lib/src/pigeon/personalization_api.g.dart +++ b/lib/src/pigeon/personalization_api.g.dart @@ -862,6 +862,141 @@ 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 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) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as String?)!; + } + } + /// [customJson] and [recommendedSourceJson] are JSON object strings or null. Future trackPurchase( String orderId, diff --git a/lib/src/products/product_counters_response.dart b/lib/src/products/product_counters_response.dart new file mode 100644 index 0000000..9f1f2f7 --- /dev/null +++ b/lib/src/products/product_counters_response.dart @@ -0,0 +1,70 @@ +import '../json_number.dart'; + +/// 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: toIntOrNull(json['view']) ?? 0, + cart: toIntOrNull(json['cart']) ?? 0, + purchase: toIntOrNull(json['purchase']) ?? 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: 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..8b17500 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,19 @@ 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, - relativeSalesRate: - (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + salesRate: toIntOrNull(json['sales_rate']) ?? 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)) @@ -122,8 +122,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/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/lib/src/recommendation/recommendation_response.dart b/lib/src/recommendation/recommendation_response.dart index 95bce98..79681c8 100644 --- a/lib/src/recommendation/recommendation_response.dart +++ b/lib/src/recommendation/recommendation_response.dart @@ -1,3 +1,5 @@ +import '../json_number.dart'; + /// Typed response for [PersonalizationSdk.getRecommendation]. /// /// Contains only fields present in **both** Android and iOS native SDK models. @@ -91,7 +93,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 +106,13 @@ 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, - relativeSalesRate: - (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + salesRate: toIntOrNull(json['sales_rate']) ?? 0, + relativeSalesRate: 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..652dbb2 100644 --- a/lib/src/search/search_response.dart +++ b/lib/src/search/search_response.dart @@ -1,3 +1,5 @@ +import '../json_number.dart'; + /// Response from [PersonalizationSdk.searchFull]. /// /// Excluded Android-only response fields: brands (`List`), clarification, @@ -26,7 +28,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 +86,19 @@ 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, - relativeSalesRate: - (json['relative_sales_rate'] as num?)?.toDouble() ?? 0.0, + salesRate: toIntOrNull(json['sales_rate']) ?? 0, + relativeSalesRate: 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?, ); 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/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" diff --git a/test/catalog_test.dart b/test/catalog_test.dart new file mode 100644 index 0000000..eda4052 --- /dev/null +++ b/test/catalog_test.dart @@ -0,0 +1,189 @@ +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()), + ); + }); + }); +}