diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52cc184d..7a1b0bfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -126,7 +126,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Java uses: actions/setup-java@v5 @@ -180,7 +180,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -235,7 +235,7 @@ jobs: needs: [analyze] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -273,7 +273,7 @@ jobs: needs: [analyze] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Java uses: actions/setup-java@v5 @@ -323,7 +323,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Check version consistency run: | @@ -358,7 +358,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Validate podspec working-directory: purchasely/ios diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 00000000..61140b9a --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -0,0 +1,121 @@ +name: E2E Android + +# End-to-end Dart <-> Android tests against the REAL Purchasely backend, run on a +# real Android emulator. These are NOT part of the PR-gating `ci.yml` (they need an +# emulator, real network, and the uiautomator drivers) — they run on demand and +# nightly. +# +# Triggers: +# * pull_request — run on PRs targeting main while the v6 migration is in +# flight, so the E2E suite gates the merge. Scoped to +# paths that affect the bridge / native layer / tests. +# * workflow_dispatch — manual run (any branch the workflow exists on) +# * schedule — nightly at 03:00 UTC (GitHub only fires schedules from the +# repository's DEFAULT branch, so the nightly run activates +# once this file is merged to main). +on: + pull_request: + branches: [main] + paths: + - "purchasely/lib/**" + - "purchasely/android/**" + - "purchasely/example/integration_test/**" + - ".github/workflows/e2e-android.yml" + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +concurrency: + group: e2e-android-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-android: + name: E2E Android (real backend) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Enable KVM (hardware acceleration for the emulator) + 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 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.24.x" + channel: stable + cache: true + + # The native Purchasely Android SDK (io.purchasely:core / google-play / player) + # 6.0.0-rc.2 is published to Maven Central, so Gradle resolves it directly — + # no mavenLocal priming needed. + + - name: Cache pub packages + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('purchasely/pubspec.lock', 'purchasely/example/pubspec.lock') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install Flutter deps (purchasely) + working-directory: purchasely + run: flutter pub get + + - name: Install Flutter deps (example) + working-directory: purchasely/example + run: flutter pub get + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-34-google_apis-x86_64-pixel_6 + + - name: Create AVD + 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 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: echo "Generated AVD snapshot for caching." + + - name: Run E2E suite on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: bash purchasely/example/integration_test/tools/ci_run_e2e.sh emulator-5554 + + - name: Upload E2E logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-android-logs + path: purchasely/example/integration_test/ci-logs/ + retention-days: 7 diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 00000000..d048c563 --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -0,0 +1,130 @@ +name: E2E iOS + +# End-to-end Dart <-> iOS tests against the REAL Purchasely backend, run on an +# iOS Simulator. These are NOT part of the PR-gating `ci.yml` (they need a +# simulator and real network) — they run on demand and nightly. +# +# Suites: +# 1/3 — dart_ios_bridge_test.dart (T1–T20, no native interaction) +# 2/3 — interceptor_trigger_ios_test (purchase interceptor; driver taps +# ply_action_purchase_* via idb) +# 3/3 — default_dismiss_handler_ios (deeplink + default dismiss; driver +# taps ply_action_close via idb) +# +# Triggers: +# * pull_request — run on PRs targeting main while the v6 migration is in +# flight, so the E2E suite gates the merge. Scoped to +# paths that affect the bridge / native layer / tests. +# * workflow_dispatch — manual run (any branch the workflow exists on) +# * schedule — nightly at 04:00 UTC (GitHub only fires schedules from +# the repository's DEFAULT branch, so the nightly run +# activates once this file is merged to main). +on: + pull_request: + branches: [main] + paths: + - "purchasely/lib/**" + - "purchasely/ios/**" + - "purchasely/example/integration_test/**" + - ".github/workflows/e2e-ios.yml" + workflow_dispatch: + schedule: + - cron: "0 4 * * *" + +concurrency: + group: e2e-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-ios: + name: E2E iOS (real backend) + runs-on: macos-latest + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + # Match the dev toolchain (3.41.x). Flutter 3.24.5 + the macos-latest + # Xcode/iOS runtime makes the example app crash on launch on the + # simulator (the integration-test harness then times out after 12 min + # with setUpAll never running). 3.41.x runs the full suite locally. + flutter-version: "3.41.4" + channel: stable + cache: true + + - name: Cache pub packages + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('purchasely/pubspec.lock', 'purchasely/example/pubspec.lock') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install Flutter deps (purchasely) + working-directory: purchasely + run: flutter pub get + + - name: Install Flutter deps (example) + working-directory: purchasely/example + run: flutter pub get + + - name: Cache CocoaPods + uses: actions/cache@v4 + with: + path: | + purchasely/example/ios/Pods + ~/Library/Caches/CocoaPods + key: ${{ runner.os }}-pods-${{ hashFiles('purchasely/example/ios/Podfile.lock') }} + restore-keys: ${{ runner.os }}-pods- + + - name: Install CocoaPods deps + working-directory: purchasely/example/ios + # No --repo-update: the CDN resolves specs without cloning the full repo. + run: pod install + + - name: Install idb-companion + idb Python client + run: | + # idb-companion lives in the facebook/fb tap, not homebrew-core. + brew install facebook/fb/idb-companion + # macOS runners' Python may be externally-managed (PEP 668). + pip3 install fb-idb || pip3 install --break-system-packages fb-idb + + - name: Boot iOS Simulator + id: boot-sim + run: | + # Find an available iPhone simulator (prefer iPhone 16, fall back to 15/14) + UDID=$(xcrun simctl list devices available | \ + grep -E "iPhone (16|15|14)" | head -1 | \ + grep -oE '[0-9A-F-]{36}') + if [ -z "$UDID" ]; then + echo "No iPhone simulator found" >&2 + xcrun simctl list devices available >&2 + exit 1 + fi + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + echo "Booting simulator $UDID" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b + + - name: Run E2E suite on iOS Simulator + run: bash purchasely/example/integration_test/tools/ci_run_e2e_ios.sh ${{ steps.boot-sim.outputs.udid }} + + - name: Collect simulator crash logs (on failure) + if: failure() + run: | + mkdir -p purchasely/example/integration_test/ci-logs + cp -R ~/Library/Logs/DiagnosticReports \ + purchasely/example/integration_test/ci-logs/DiagnosticReports 2>/dev/null || true + xcrun simctl spawn ${{ steps.boot-sim.outputs.udid }} log show --last 5m \ + --predicate 'processImagePath CONTAINS "Runner" OR senderImagePath CONTAINS "Purchasely"' \ + > purchasely/example/integration_test/ci-logs/sim_log.txt 2>&1 || true + + - name: Upload E2E logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-ios-logs + path: purchasely/example/integration_test/ci-logs/ + retention-days: 7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index da3605d3..d4b81fda 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -143,7 +143,7 @@ jobs: needs: [publish-google, publish-player] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Summary run: | diff --git a/MIGRATION-v6.md b/MIGRATION-v6.md new file mode 100644 index 00000000..beb6c9a9 --- /dev/null +++ b/MIGRATION-v6.md @@ -0,0 +1,563 @@ +# Migrating to the Purchasely 6.0 native SDK (Flutter) + +This release **adapts the Purchasely Flutter plugin to the Purchasely 6.0 native +SDKs** (iOS `Purchasely 6.0.0-rc.1`, Android `io.purchasely:core 6.0.0-rc.1`). + +Three areas are breaking changes: **starting the SDK**, **displaying / preloading / +closing a presentation**, and the **action interceptor**. Everything else on the +`Purchasely` class — purchases, restore, identity, catalog, subscriptions, user +attributes, events, dynamic offerings, consent and config — remains +source-compatible except for removed v5 aliases. Deeplinks use the v6 names +(`allowDeeplink`, `handleDeeplink`). + +A paywall is now called a **Presentation** (or *Screen*). + +> **Tip — let the AI help you migrate.** The Purchasely AI plugin and the +> `purchasely-integrate`, `purchasely-review` and `purchasely-debug` skills can +> read your integration and rewrite the old paywall calls to the new builder +> API for you. Point them at the files that call `Purchasely.start(...)`, +> `Purchasely.presentPresentationForPlacement(...)`, +> `Purchasely.fetchPresentation(...)`, +> `Purchasely.setPaywallActionInterceptorCallback(...)`, etc. + +--- + +## Changelog + +### Breaking type renames (v5 → v6) + +These v5 types have been renamed or restructured. Update all usages. + +| Old (v5) | New (v6) | +|---|---| +| `PresentPresentationResult` | `PLYPresentationOutcome` | +| `PLYPaywallAction` | `PLYPresentationActionKind` | +| `PLYPaywallInfo` | `PLYInterceptorInfo` | +| `PLYPaywallActionParameters` | `PLYActionPayload` (+ typed `PLY*Payload` subclasses) | +| `PaywallActionInterceptorResult` | callback split into `(PLYInterceptorInfo, PLYActionPayload?, PLYActionInterceptorHandler)` — see [Action interceptor](#action-interceptor) | + +**`PLYRunningMode` values changed.** The old (v5-era) `PLYRunningMode` had four +values: `transactionOnly`, `observer`, `paywallObserver`, `full`. The new enum +only has `observer` (index 0) and `full` (index 1). Any reference to +`PLYRunningMode.transactionOnly` or `PLYRunningMode.paywallObserver` must be +removed. + +**New `PLYTransition` factory constructors.** `PLYTransition.drawer()` and +`PLYTransition.popin()` are now available, mirroring `PLYTransition.modal()` and +`PLYTransition.fullScreen()`. See [Sized transitions](#sized-transitions-drawer--popin--breaking) +below. + +**`.preload().display()` chain.** An extension on `Future` lets +you chain `preload()` directly into `display()` without a separate `await`: + +```dart +final outcome = await PLYPresentationBuilder.placement('onboarding') + .build() + .preload() + .display(const PLYTransition.drawer(height: PLYTransitionDimension.percentage(0.5))); +``` + +--- + +## TL;DR + +- Start the SDK with the fluent builder: + `Purchasely.apiKey('…').runningMode(PLYRunningMode.full).start()`. +- Build a presentation with `PLYPresentationBuilder` + (`.placement(id)`, `.screen(id)`, `.defaultSource()`), then `.build()` to get + a **`PLYPresentationRequest`** with a lifecycle (`preload()`, + `display([transition])`). +- `display([PLYTransition])` resolves at **dismiss** with a 5-field + **`PLYPresentationOutcome`** (`presentation`, `purchaseResult`, `plan`, + `closeReason`, `error`). +- A loaded `PLYPresentation` exposes `display()`, `close()` and `back()` for + programmatic control. +- The interceptor is now + `Purchasely.interceptAction(kind, handler)`, where + `handler` returns a `PLYInterceptResult` (`success` / `failed` / `notHandled`). +- Inline rendering uses the `PLYPresentationView` widget. +- Other `Purchasely.*` methods remain source-compatible; deeplinks use the v6 + names — see [What's unchanged](#whats-unchanged). + +--- + +## Removed / changed API → new equivalent + +These were the paywall-related entry points on the `Purchasely` class. They have +been removed in favour of the builder API. + +| Old (`Purchasely.*`, removed) | New | +|-------------------------------|-----| +| `Purchasely.start(apiKey: …, androidStores: …, storeKit1: …, logLevel: …, runningMode: …, userId: …)` | `Purchasely.apiKey('…').appUserId(userId).runningMode(PLYRunningMode.full).logLevel(PLYLogLevel.error).stores([PLYStore.google]).storekitVersion(PLYStorekitVersion.storeKit2).start()` | +| `Purchasely.fetchPresentation(placementId: id)` | `PLYPresentationBuilder.placement(id).build().preload()` | +| `Purchasely.presentPresentationForPlacement(id, isFullscreen: …)` | `PLYPresentationBuilder.placement(id).build().display(const PLYTransition.fullScreen())` | +| `Purchasely.presentPresentationWithIdentifier(presentationId, …)` | `PLYPresentationBuilder.screen(id).build().display(const PLYTransition.modal())` | +| `Purchasely.presentPresentation(presentation)` | preload then display the same request: `final req = PLYPresentationBuilder.placement(id).build(); await req.preload(); await req.display();` | +| `Purchasely.presentProductWithIdentifier(productId, …)` | `PLYPresentationBuilder.screen(id).contentId(contentId).build().display()` | +| `Purchasely.presentPlanWithIdentifier(planId, …)` | `PLYPresentationBuilder.screen(id).build().display()` | +| `Purchasely.getPresentationView(...)` | the `PLYPresentationView(request: …)` widget | +| `Purchasely.closePresentation()` / `hidePresentation()` / `close()` | `presentation.close()` (on the loaded `PLYPresentation`) | +| `Purchasely.showPresentation()` | `presentation.display()` (on the loaded `PLYPresentation`) | +| `Purchasely.clientPresentationDisplayed(...)` / `clientPresentationClosed(...)` | handled via the `PLYPresentationRequest` lifecycle (`preload` → inspect `PLYPresentationType.client` → render your own UI) | +| `Purchasely.setDefaultPresentationResultHandler(cb)` / `setDefaultPresentationResultCallback(cb)` | `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` — receives `PLYPresentationOutcome` (`presentation`, `purchaseResult`, `plan`, `closeReason`, `error`) | +| `Purchasely.setPaywallActionInterceptorCallback(cb)` + `Purchasely.onProcessAction(bool)` | `Purchasely.interceptAction(kind, handler)` — handler returns `PLYInterceptResult.success` / `.failed` / `.notHandled` (no more `onProcessAction`) | + +> **Reminder.** Everything *not* in this table — purchases, restore, login, +> attributes, subscriptions, products, events, offerings, consent and config — +> keeps source-compatible `Purchasely.*` signatures. Deeplinks use the v6 names +> documented below. + +--- + +## Initialization + +### Before + +```dart +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +bool configured = await Purchasely.start( + apiKey: '', + androidStores: ['Google'], + storeKit1: false, + logLevel: PLYLogLevel.error, + runningMode: PLYRunningMode.full, + userId: 'user_id', +); + +Purchasely.readyToOpenDeeplink(true); // removed in v6; use allowDeeplink +``` + +### After + +```dart +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +final bool configured = await Purchasely.apiKey('') + .appUserId('user_id') // optional, defaults to anonymous + .runningMode(PLYRunningMode.full) // PLYRunningMode.observer (default) | full + .logLevel(PLYLogLevel.error) // debug | info | warn | error + .allowDeeplink(true) // allow the SDK to open deeplinks + .allowCampaigns(true) // optional campaign display gate + .stores([PLYStore.google]) // Android only: google | huawei | amazon + .storekitVersion(PLYStorekitVersion.storeKit2) // iOS only: storeKit2 (default) | storeKit1 + .start(); +``` + +> **Default running mode changed.** With the 6.0 native SDK the default +> `PLYRunningMode` is `PLYRunningMode.observer` — the host app keeps control of +> the purchase flow unless it opts into `PLYRunningMode.full`. Pass +> `.runningMode(PLYRunningMode.full)` to keep the previous behaviour where +> Purchasely owns the purchase flow. + +> **`allowDeeplink` replaces the old v5 name.** Allowing deeplinks can be set on +> the builder or toggled later with `Purchasely.allowDeeplink(bool)`. +> `readyToOpenDeeplink` was removed from the Flutter v6 API. + +--- + +## Displaying a presentation + +### Before + +```dart +final result = await Purchasely.presentPresentationForPlacement( + '', + contentId: 'my_content_id', + isFullscreen: true, +); + +switch (result.result) { + case PLYPurchaseResult.purchased: + case PLYPurchaseResult.restored: + print('Purchased ${result.plan?.name}'); + break; + case PLYPurchaseResult.cancelled: + break; +} +``` + +### After + +`PLYPresentationBuilder.placement(id).build()` returns a `PLYPresentationRequest`. +Calling `display([PLYTransition])` shows the screen and resolves at **dismiss** +with a `PLYPresentationOutcome`. + +```dart +final outcome = await PLYPresentationBuilder.placement('') + .contentId('my_content_id') + .build() + .display(const PLYTransition.fullScreen()); + +// outcome: presentation, purchaseResult, plan, closeReason, error +if (outcome.error != null) { + print('Display error: ${outcome.error!.message}'); +} else if (outcome.purchaseResult == PLYPurchaseResult.purchased || + outcome.purchaseResult == PLYPurchaseResult.restored) { + print('Purchased ${outcome.plan?.name}'); +} else { + print('Dismissed: ${outcome.closeReason}'); // button | backSystem | programmatic +} +``` + +`purchaseResult` is the `PLYPurchaseResult` enum +(`purchased` / `cancelled` / `restored`) and is `null` when the user dismissed +the screen without a purchase action. + +`plan` is a fully-typed **`PLYPlan?`** — the same model returned by +`planWithIdentifier` and carried by a purchase interceptor's `PLYPurchasePayload`. +Read its fields directly (`outcome.plan?.vendorId`, `outcome.plan?.name`, +`outcome.plan?.amount`, …). It is `null` when no purchase action produced a plan. + +> **iOS / Android `closeReason` parity.** Both native 6.0 SDKs now expose +> `closeReason` on the outcome, and Flutter surfaces it on both platforms +> (`button` / `backSystem` / `programmatic`). iOS maps its +> `interactiveDismiss` (swipe-down / nav-pop) to `backSystem` to stay aligned +> with Android's `BACK_SYSTEM`. The only field still iOS-`null` is the loaded +> presentation `contentId` (`PLYPresentation` does not expose it on iOS); +> Android 6.0 reports it. + +> **Plan offer fields.** Android 6.0 renamed introductory-price helpers to +> offer-price helpers. Flutter now exposes the v6 names (`hasOfferPrice`, +> `offerPrice`, `offerAmount`, `offerDuration`, `offerPeriod`) and keeps the old +> `intro*` fields populated as deprecated compatibility aliases. + +### Targeting a specific screen / product + +```dart +// A specific presentation by screen id (was presentPresentationWithIdentifier) +await PLYPresentationBuilder.screen('SCREEN_ID').build().display(const PLYTransition.modal()); + +// A specific product / content inside a screen (was presentProductWithIdentifier) +await PLYPresentationBuilder.screen('SCREEN_ID').contentId('CONTENT_ID').build().display(); +``` + +### Sized transitions (`drawer` / `popin`) — BREAKING + +`Transition.heightPercentage` was **removed**. Drawer and popin transitions are +now sized with the native dimension model, mirroring Android's +`PLYTransitionDimension`. Use the `width` (popin only) and `height` (drawer + +popin) fields with a `PLYTransitionDimension`, expressed as a `percentage` +(`0.0`–`1.0`) or fixed `pixel` value. Leave a dimension `null` to size to +content ("hug"). + +Named factory constructors are provided for `drawer` and `popin` (like +`PLYTransition.modal()` and `PLYTransition.fullScreen()`): + +```dart +// Before (v5 / removed): +// Transition(type: TransitionType.drawer, heightPercentage: 0.5); + +// After — factory constructors (preferred): +const PLYTransition.drawer(height: PLYTransitionDimension.percentage(0.5)); +const PLYTransition.drawer(height: PLYTransitionDimension.pixel(300)); + +const PLYTransition.popin( + width: PLYTransitionDimension.pixel(320), + height: PLYTransitionDimension.percentage(0.6), + dismissible: false, +); + +// After — explicit constructor (equivalent): +const PLYTransition( + type: PLYTransitionType.drawer, + height: PLYTransitionDimension.percentage(0.5), +); +``` + +Available factory constructors on `PLYTransition`: + +| Constructor | Description | +|---|---| +| `PLYTransition.fullScreen()` | Full-screen (default) | +| `PLYTransition.modal({bool? dismissible})` | Modal sheet | +| `PLYTransition.push()` | Push / navigation | +| `PLYTransition.drawer({PLYTransitionDimension? height, bool? dismissible, PLYTransitionColors? backgroundColors})` | Bottom drawer with optional height | +| `PLYTransition.popin({PLYTransitionDimension? width, PLYTransitionDimension? height, bool? dismissible, PLYTransitionColors? backgroundColors})` | Floating pop-in with optional dimensions | + +--- + +## Preloading (pre-fetch) + +### Before + +```dart +final presentation = await Purchasely.fetchPresentation(placementId: ''); +final result = await Purchasely.presentPresentation(presentation); +``` + +### After + +Build a `PLYPresentationRequest`, `preload()` it to fetch the screen from the +network, then `display()` the loaded `PLYPresentation` when you are ready. + +**Pattern A — separate preload and display** (preload early, display later): + +```dart +final request = PLYPresentationBuilder.placement('').build(); + +final presentation = await request.preload(); // resolves when the screen is loaded + +if (presentation.type == PLYPresentationType.deactivated) { + return; // No paywall to display for this placement +} +if (presentation.type == PLYPresentationType.client) { + return; // Display your own paywall (BYOS) — plan summaries are in presentation.plans +} + +// Later, when ready to show it; resolves at dismiss +final outcome = await presentation.display(const PLYTransition.fullScreen()); +``` + +**Pattern B — chained preload and display** (preload + display in one expression): + +```dart +final outcome = await PLYPresentationBuilder.placement('') + .build() + .preload() + .display(const PLYTransition.drawer(height: PLYTransitionDimension.percentage(0.5))); +``` + +> `preload()` on `PLYPresentationRequest` returns `Future`. The +> `display([PLYTransition?])` method is available both on `PLYPresentation` +> directly (Pattern A) and via a `Future` extension (Pattern B). + +--- + +## Presentation lifecycle (display / close / back) + +The imperative `showPresentation` / `hidePresentation` / `closePresentation` +methods are replaced by methods on the loaded `PLYPresentation` handle (the one +you get from `preload()`, or from `outcome.presentation`): + +```dart +final presentation = await PLYPresentationBuilder.placement('ONBOARDING').build().preload(); + +presentation.display(); // show (returns a future that resolves at dismiss) +presentation.close(); // dismiss programmatically +presentation.back(); // navigate back inside a multi-step (Flow) presentation +``` + +--- + +## Action interceptor + +`setPaywallActionInterceptorCallback` + `onProcessAction` are replaced by +`Purchasely.interceptAction(kind, handler)`. Register +**one handler per action kind**; the handler returns a `PLYInterceptResult` +(`success` / `failed` / `notHandled`) instead of calling +`onProcessAction(true/false)`. + +### Before + +```dart +Purchasely.setPaywallActionInterceptorCallback((info, action, parameters, processAction) { + if (action == PLYPaywallAction.purchase) { + MyPurchaseSystem.purchase(parameters.plan.productId); + Purchasely.onProcessAction(false); + } else { + Purchasely.onProcessAction(true); + } +}); +``` + +### After + +```dart +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) async { + if (payload is PLYPurchasePayload) { + final ok = await MyPurchaseSystem.purchase(payload.plan.productId); + return ok ? PLYInterceptResult.success : PLYInterceptResult.failed; + } + return PLYInterceptResult.notHandled; + }, +); + +await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (info, payload) async { + if (payload is PLYNavigatePayload) { + // open payload.url with your router / url_launcher + return PLYInterceptResult.success; + } + return PLYInterceptResult.notHandled; + }, +); + +// Cleanup +await Purchasely.removeActionInterceptor(PLYPresentationActionKind.purchase); +await Purchasely.removeAllActionInterceptors(); +``` + +Action kinds (`PLYPresentationActionKind`): `close`, `closeAll`, `login`, +`navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, +`promoCode`, `webCheckout`. Each kind has a typed payload +(`PLYNavigatePayload`, `PLYPurchasePayload`, `PLYClosePayload`, +`PLYCloseAllPayload`, `PLYOpenPresentationPayload`, `PLYOpenPlacementPayload`, +`PLYWebCheckoutPayload`); payload-less kinds (`login`, `restore`, `promoCode`) +carry no extra fields. + +--- + +## Deeplinks, campaigns & default dismiss handler + +```dart +// Allow deeplinks and campaigns at start: +await Purchasely.apiKey('') + .allowDeeplink(true) + .allowCampaigns(true) + .start(); + +// These runtime gates are independent. +await Purchasely.allowDeeplink(true); +await Purchasely.allowCampaigns(false); + +// Default dismiss handler (renamed from setDefaultPresentationResultHandler). +// Used for presentations opened by the SDK itself: campaigns, deeplinks, +// promoted in-app purchases. +await Purchasely.setDefaultPresentationDismissHandler((outcome) { + print('SDK presentation dismissed: ${outcome.presentation?.screenId} / ' + '${outcome.purchaseResult} / ${outcome.closeReason}'); +}); + +// v6 deeplink handler: +final handled = await Purchasely.handleDeeplink('app://ply/presentations/'); +``` + +### Where the dismiss outcome is delivered (routing) + +A dismissed presentation produces one `PLYPresentationOutcome`. There are three +ways to receive it: + +| Channel | What it is | +|---------|------------| +| `await display()` | the **return value** — you await the call and get the outcome inline | +| `onDismissed` | a **per-presentation** callback attached to *this* request/presentation | +| `setDefaultPresentationDismissHandler` | a single **global** handler for the whole app | + +**Routing rule:** at dismiss, the outcome goes to the **`onDismissed` handler if +one is set, otherwise to the global default handler.** The deciding factor is the +*presence of `onDismissed`* — not whether you awaited the future. Awaiting +`display()` always gives you the outcome as a return value, but it does **not** by +itself suppress the global handler. + +```dart +await Purchasely.setDefaultPresentationDismissHandler((outcome) { + print('caught globally: ${outcome.purchaseResult}'); +}); + +// (A) fire-and-forget, no onDismissed → the GLOBAL handler receives it. +PLYPresentationBuilder.placement('PLACEMENT').build().display(); + +// (B) local onDismissed set → the LOCAL handler receives it, global stays silent. +PLYPresentationBuilder.placement('PLACEMENT') + .onDismissed((outcome) => print('caught locally')) + .build() + .display(); + +// (C) await without onDismissed → the return value AND the global handler both +// receive it (set an onDismissed if you want the global to stay silent). +final outcome = + await PLYPresentationBuilder.placement('PLACEMENT').build().display(); + +// (D) await + onDismissed → return value + local handler receive it, global silent. +``` + +> **Rule of thumb:** pick *one* channel per presentation — await it, **or** set +> `onDismissed`, **or** leave both off and let the global handler catch it. +> The global handler is also the path for presentations the SDK opens itself +> (campaigns, deeplinks, promoted in-app purchases), which have no host-side +> `display()` call to await. + +--- + +## Inline (embedded) presentations + +To render a presentation inline inside your widget tree, use the +`PLYPresentationView` widget with a `PLYPresentationRequest`. The widget preloads +the request and hands the result to the native inline view. + +```dart +import 'package:purchasely_flutter/native_view_widget.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +final request = PLYPresentationBuilder.placement('onboarding') + .onDismissed((outcome) => print('inline dismissed: ${outcome.purchaseResult}')) + .build(); + +// In your build(): +PLYPresentationView(request: request); +``` + +--- + +## What's unchanged + +Only the **paywall surface** (start, display / preload / close / back, and the +action interceptor) has breaking API changes. Every other `Purchasely.*` method +remains source-compatible except for removed v5 aliases; deeplinks use v6 names: + +- **Purchases**: `purchaseWithPlanVendorId`, `signPromotionalOffer`. +- **Restore**: `restoreAllProducts`, `silentRestoreAllProducts`, + `userDidConsumeSubscriptionContent`. +- **Identity**: `userLogin`, `userLogout`, `isAnonymous`, `anonymousUserId`. +- **Catalog**: `allProducts`, `productWithIdentifier`, `planWithIdentifier`, + `isEligibleForIntroOffer`. +- **Subscriptions data**: `userSubscriptions`, `userSubscriptionsHistory`, + `displaySubscriptionCancellationInstruction` (no-op on both platforms — see + callout below). Note: `presentSubscriptions()` was **removed** (see callout). +- **User attributes**: `setUserAttributeWithString` / `WithInt` / `WithDouble` / + `WithBoolean` / `WithDate` / `WithStringArray` / `WithIntArray` / + `WithDoubleArray` / `WithBooleanArray`, `incrementUserAttribute`, + `decrementUserAttribute`, `userAttribute`, `userAttributes`, + `clearUserAttribute`, `clearUserAttributes`, `clearBuiltInAttributes`, + `setAttribute`, `setUserAttributeListener` / `clearUserAttributeListener`. +- **Events**: `listenToEvents` / `stopListeningToEvents`, `listenToPurchases` / + `stopListeningToPurchases`. +- **Dynamic offerings**: `setDynamicOffering`, `getDynamicOfferings`, + `removeDynamicOffering`, `clearDynamicOfferings`. +- **Consent**: `revokeDataProcessingConsent`. +- **Config / misc**: `setLanguage`, `setThemeMode`, `setLogLevel`, + `synchronize`, `allowDeeplink`, `allowCampaigns`, `handleDeeplink`, + `setDebugMode`. (`readyToOpenDeeplink` / `isDeeplinkHandled` were removed.) + +> **`synchronize()` now reports completion (BREAKING signature).** The 6.0 +> native SDKs expose success/error callbacks on `synchronize()` (Android +> `synchronize(onSuccess, onError)`, iOS `synchronize(success:failure:)`). +> `Purchasely.synchronize()` now returns **`Future`** (was `Future`): +> it **resolves with `true` when the synchronization actually completes** and +> **throws a `PlatformException` on failure**, instead of the previous +> fire-and-forget behaviour. `await` it (and optionally `try/catch`) before +> chaining a follow-up presentation that targets subscribers. + +> **Removed `presentSubscriptions()` (BREAKING).** The native subscriptions +> screen was removed from the 6.0 SDKs on both platforms (the iOS +> `subscriptionsController()` entry point no longer exists in native 6.0, and +> Android dropped its built-in screen). `Purchasely.presentSubscriptions()` has +> therefore been **removed entirely** from the Flutter API on every layer (Dart, +> iOS, Android) — it is no longer a no-op, the method no longer exists. There is +> no drop-in replacement: build your own subscriptions screen with +> `userSubscriptions()` / `userSubscriptionsHistory()`. +> +> The cancellation survey UI was likewise removed, so +> `Purchasely.displaySubscriptionCancellationInstruction()` is kept for source +> compatibility but is a **no-op on both Android and iOS**. + +> **Native dependency.** This release targets the Purchasely 6.0 native SDKs, +> pinned to the **`6.0.0-rc.1`** pre-release on both platforms +> (Android `io.purchasely:core` / `google-play` / `player` `6.0.0-rc.1`; +> iOS `Purchasely` `6.0.0-rc.1`). Both are published — Android on **Maven +> Central**, iOS on the **CocoaPods trunk** — so the project builds from the +> public repositories with no `mavenLocal()` and no development pod. + +--- + +## Need a hand? + +Use the Purchasely AI plugin / skills (`purchasely-integrate`, +`purchasely-review`, `purchasely-debug`) to scan your project and apply this +migration automatically. diff --git a/README.md b/README.md index 3c52c31c..57d7234a 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,16 @@ Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery. +> **Upgrading to 6.0?** The paywall surface (start, display/preload/close, action +> interceptor) moved to a fluent builder API; other `Purchasely` APIs remain +> source-compatible (deeplinks use v6 names). See +> [`MIGRATION-v6.md`](./MIGRATION-v6.md) for the complete old→new mapping. + ## Installation -``` +```yaml dependencies: - purchasely_flutter: ^5.1.2 + purchasely_flutter: 6.0.0-rc.1 ``` ## Usage @@ -16,35 +21,36 @@ dependencies: ```dart import 'package:purchasely_flutter/purchasely_flutter.dart'; -// ... - -bool configured = await Purchasely.start( - apiKey: '', - androidStores: ['Google, Huawei, Amazon'], - storeKit1: false, - logLevel: PLYLogLevel.error, - runningMode: PLYRunningMode.full, - userId: null, -); - -var result = await Purchasely.presentPresentationForPlacement("", isFullscreen: true); - -switch (result.result) { - case PLYPurchaseResult.cancelled: - { - print("User cancelled purchased"); - } - break; - case PLYPurchaseResult.purchased: - { - print("User purchased ${result.plan?.name}"); - } - break; - case PLYPurchaseResult.restored: - { - print("User restored ${result.plan?.name}"); - } - break; +// 1. Start the SDK. +await PurchaselyBuilder.apiKey('') + .runningMode(RunningMode.full) + .logLevel(LogLevel.error) + .stores([PLYStore.google]) + .start(); + +// Runtime deeplink toggle (v6 name): +await Purchasely.allowDeeplink(true); + +// 2. Build a presentation request and display it. +// `.display(...)` resolves at *dismiss* time with the 5-field +// `PresentationOutcome` (presentation, purchaseResult, plan, closeReason, error). +final outcome = await PresentationBuilder.placement('') + .build() + .display(const Transition.fullScreen()); + +switch (outcome.purchaseResult) { + case PurchaseResult.cancelled: + print('User cancelled'); + break; + case PurchaseResult.purchased: + print('User purchased ${outcome.plan}'); + break; + case PurchaseResult.restored: + print('User restored ${outcome.plan}'); + break; + case null: + print('Dismissed without a purchase action'); + break; } ``` diff --git a/V6_MIGRATION_REPORT.md b/V6_MIGRATION_REPORT.md new file mode 100644 index 00000000..cd6dae2f --- /dev/null +++ b/V6_MIGRATION_REPORT.md @@ -0,0 +1,418 @@ +# Rapport de migration Flutter → Purchasely SDK natif 6.0 + +> Session du 2026-06-15 sur la branche `feat/sdk-v6-migration`. +> Ce document récapitule **tout ce qui a été fait** pour finaliser la migration +> du plugin Flutter vers les SDK natifs Purchasely 6.0, et sert de source pour +> mettre à jour `../Documentation` (docs publiques) et `../purchasely-ai-skill` +> (références `flutter/`), comme cela a été fait pour Android et iOS. + +--- + +## 1. Contexte et principe + +Le plugin Flutter Purchasely est un **bridge Dart ↔ natif** (MethodChannel / +EventChannel) vers les SDK natifs iOS (`Purchasely`) et Android +(`io.purchasely:core`). Cette migration **adapte le bridge aux SDK natifs 6.0**. + +Principe directeur (validé avec le demandeur) : + +- **Pas de nommage « v6 » dans l'API Dart.** Les nouvelles méthodes **remplacent** + l'existant. Quand il n'y a pas de nouvelle méthode native (ex. `setUserAttribute*`, + `synchronize`), on **laisse en l'état**. +- Trois zones sont des breaking changes : **démarrage du SDK**, **affichage / + preload / fermeture d'une présentation**, et **l'action interceptor**. Le reste + de la surface `Purchasely.*` reste source-compatible. Les deeplinks prennent les + noms v6 (`allowDeeplink`, `handleDeeplink`) avec alias dépréciés. + +L'essentiel de la couche Dart, du bridge Android et du bridge iOS **existait déjà** +sur la branche au début de la session (le modèle mental « iOS pas commencé » était +obsolète — iOS était en réalité très avancé). Le travail de cette session a consisté +à : **vérifier la compilation contre les vrais SDK natifs v6**, **corriger les +divergences d'API**, **ajouter le callback à `synchronize`**, **compléter les tests**, +et **valider sur simulateurs**. + +--- + +## 2. Changements effectués cette session + +### 2.1 `synchronize()` — ajout du callback (Dart + Android + iOS) + +Les SDK natifs 6.0 exposent désormais des callbacks succès/erreur sur +`synchronize()`. Le bridge a été câblé pour en profiter : + +- **Dart** (`lib/purchasely_flutter.dart`) : `synchronize()` garde sa signature + `Future` mais **résout réellement à la fin de la synchronisation** et + **lève une `PlatformException` en cas d'échec** (au lieu du fire-and-forget). + Source-compatible pour le code qui faisait déjà `await`. +- **Android** (`PurchaselyFlutterPlugin.kt`) : `synchronize(result)` appelle + `Purchasely.synchronize(onSuccess = { result.success(true) }, onError = { e -> result.error(...) })` + (signature native `synchronize(onSuccess: (PLYPlan?) -> Unit, onError: (PLYError?) -> Unit)`). + Avant, le bridge appelait `Purchasely.synchronize()` puis `result.success(true)` + immédiatement (sans attendre). +- **iOS** (`SwiftPurchaselyFlutterPlugin.swift`) : les callbacks de + `Purchasely.synchronize(success:failure:)` étaient **commentés** (la `Future` + Dart ne se résolvait jamais → gel). Décommentés → `result(true)` / `result(error)`. + +### 2.2 Corrections de compilation iOS (contre le SDK natif `develop` / 6.0.0-rc.1) + +- `PLYPresentationBuilder.from(presentationId:)` **n'existe pas** en v6 → + remplacé par `PLYPresentationBuilder.from(screenId:)`. +- `PLYPresentationOutcome(purchaseResult:plan:)` (init 2 args) **n'existe pas** → + remplacé par l'init 0-arg `PLYPresentationOutcome()` (présent dans + `SwiftPurchaselyFlutterPlugin.swift` et `NativeView.swift`). +- `Purchasely.subscriptionsController()` **supprimé** en v6 → `presentSubscriptions` + a été **entièrement retiré** du SDK Flutter (alignement React Native). (Voir §2.5.) +- Transitions `drawer`/`popin` : passage de l'API dépréciée + `.drawer(heightPercentage:dismissible:)` à `.drawer(height: .percentage(...), dismissible:)` + (et `popin(width:nil, height:.percentage(...), ...)`). +- **Bonus** : iOS v6 expose maintenant `PLYPresentationOutcome.closeReason` (la doc + disait le contraire). Le bridge le mappe désormais via `closeReason.rawDescription` + (`button` / `back_system` / `programmatic`), au lieu de toujours envoyer `null`. + +### 2.3 Corrections de compilation Android (contre `io.purchasely:core:6.0.0-rc.1`) + +- Constructeur `PLYTransition` : l'ordre des paramètres a changé en v6 + (`type, width, height, heightPercentage, backgroundColors, dismissible`). + L'appel positionnel du bridge provoquait un type-mismatch → réécrit en arguments + nommés avec le modèle moderne `PLYTransitionDimension(PLYDimensionType.PERCENTAGE, ratio)` + pour `height`. + +### 2.4 Pin des versions natives → pré-release **`6.0.0-rc.1`** (publié, dépôts publics) + +Les pins étaient sur `6.0.0` (artefact antérieur à la source vérifiée — il +manquait p.ex. `synchronize(onSuccess, onError)`). Version finale retenue : +**`6.0.0-rc.1` sur les DEUX plateformes**, qui est **réellement publiée** — +Android sur **Maven Central**, iOS sur le **CocoaPods trunk**. Conséquence : +le projet builde depuis les dépôts publics, **`mavenLocal()` retiré** (Android) +et **dev-pod retiré** (iOS) → le Podfile n'a plus de chemin absolu. + +> Attention au token exact : c'est `6.0.0-rc.1` (**avec point**) partout. Le +> `6.0.0-rc1` (sans point) n'existe QUE dans un `~/.m2` local — il est 404 sur +> Maven Central comme sur CocoaPods. + +| Fichier | Avant | Après | +|---|---|---| +| `purchasely/android/build.gradle` | `io.purchasely:core:6.0.0` | `io.purchasely:core:6.0.0-rc.1` | +| `purchasely_google/android/build.gradle` | `io.purchasely:google-play:6.0.0` | `…:6.0.0-rc.1` | +| `purchasely_android_player/android/build.gradle` | `io.purchasely:player:6.0.0` | `…:6.0.0-rc.1` | +| `purchasely/ios/purchasely_flutter.podspec` | `Purchasely '6.0.0'` | `Purchasely '6.0.0-rc.1'` | +| `purchasely/example/android/app/build.gradle` | `google-play:6.0.0`, `player:6.0.0` | `…:6.0.0-rc.1` | +| `purchasely/example/android/build.gradle` | `mavenLocal()` présent | retiré (Maven Central suffit) | +| `purchasely/example/ios/Podfile` | dev-pod `:path => '/Users/kevin/Purchasely/iOS'` | retiré (résout depuis le trunk) | + +> Même chaîne `6.0.0-rc.1` (avec point) sur les deux plateformes ; publiée sur +> Maven Central (Android) et CocoaPods trunk (iOS). `mavenLocal()` et le dev-pod +> iOS ne sont plus nécessaires. + +> ⚠️ **Piège Gradle (crash runtime trouvé par le test d'intégration).** L'`app/build.gradle` +> de l'exemple pinnait `google-play:6.0.0` / `player:6.0.0`, qui remontaient +> `core:6.0.0` transitivement. **Gradle classe `6.0.0` (release) au-dessus de +> `6.0.0-rc.1` (pré-release)** : le `core` était donc silencieusement remonté à +> `6.0.0` au runtime alors que le plugin compilait contre `6.0.0-rc.1` → +> `java.lang.NoSuchMethodError` sur le constructeur `PLYTransition` v6 +> (signature `PLYTransitionDimension` absente du `6.0.0`). Corrigé en pinnant +> aussi l'exemple sur `6.0.0-rc.1`. **À retenir** : toutes les dépendances +> `io.purchasely:*` doivent pointer la MÊME version pré-release, sinon une seule +> référence `6.0.0` perdue casse tout le runtime. + +### 2.5 `presentSubscriptions` (retiré) / `displaySubscriptionCancellationInstruction` + +Les écrans natifs d'abonnements et de désabonnement ont été retirés des SDK 6.0 +**sur les deux plateformes**. `presentSubscriptions` a donc été **entièrement +supprimé** du SDK Flutter (Dart + iOS + Android + tests + docs), pour s'aligner +sur le SDK React Native — **BREAKING CHANGE** sans remplacement : reconstruire son +propre écran via `userSubscriptions()` / `userSubscriptionsHistory()`. +`displaySubscriptionCancellationInstruction` est conservé pour la compatibilité +source mais reste un **no-op** sur Android **et** iOS. + +### 2.6 Tests ajoutés / mis à jour + +- **Dart** (`test/platform_channel_test.dart`) : 2 tests `synchronize` ajoutés — + résolution effective (await + timeout) et propagation d'erreur (`PlatformException`). +- **Android** (`PurchaselyFlutterPluginTest.kt`) : test `synchronize` câblé bout en + bout sans mock du SDK `@JvmStatic` (chemin « no store » → `onError` → `result.error`). +- **iOS** (`RunnerTests/SwiftPurchaselyFlutterPluginTests.swift`) : fichier **recréé** + (il avait été supprimé ; le `.pbxproj` le référençait encore → recâblé + automatiquement). 9 tests qui gardent la surface native v6 dont dépend le bridge + (init builder, factories `PLYPresentationBuilder`, `PLYPresentationOutcome` + + `closeReason`, enums interceptor/action, display modes). + +### 2.7 Exemple + +- `example/lib/main.dart` : `synchronize()` illustre la nouvelle sémantique + (`await` + `try/catch`). + +### 2.8 Documentation + +- `MIGRATION-v6.md` mis à jour (callback `synchronize`, parité `closeReason`, + `presentSubscriptions` retiré des deux côtés, pin natif rc1). +- Ce rapport (`V6_MIGRATION_REPORT.md`). + +### 2.9 Finalisation API publique Dart — session 2026-06-24 + +**Ce qui a été fait :** + +1. **Ajout des constructeurs nommés `PLYTransition.drawer()` et `.popin()`** + (dans `lib/src/transition.dart`), symétriques de `.modal()` et `.fullScreen()` + déjà existants. Paramètres : `height`, `width`, `dismissible`, + `backgroundColors` (tous optionnels). + +2. **Extension `Future.display()`** (dans + `lib/src/presentation.dart`) : permet de chaîner directement + `.preload().display(transition)` sans `await` intermédiaire. + +3. **Renames v5 → v6** (types supprimés ou renommés par rapport à la v5 de main) : + + | Ancien (v5) | Nouveau (v6) | + |---|---| + | `PresentPresentationResult` | `PLYPresentationOutcome` | + | `PLYPaywallAction` | `PLYPresentationActionKind` | + | `PLYPaywallInfo` | `PLYInterceptorInfo` | + | `PLYPaywallActionParameters` | `PLYActionPayload` (+ sous-classes `PLY*Payload`) | + | `PaywallActionInterceptorResult` | handler splité en `(PLYInterceptorInfo, PLYActionPayload?, PLYActionInterceptorHandler)` | + +4. **`PLYRunningMode` simplifié** : l'ancienne version avait 4 valeurs + (`transactionOnly`, `observer`, `paywallObserver`, `full`). La nouvelle n'en + a que 2 : `observer` (index 0, défaut) et `full` (index 1). Tout code sur + `transactionOnly` / `paywallObserver` doit être supprimé. + +5. **`Purchasely.apiKey(…)` comme point d'entrée SDK** : l'init se fait via + `Purchasely.apiKey('').runningMode(…).start()` — le `PurchaselyBuilder` + interne n'est pas référencé directement par l'utilisateur. + +6. **Tests mis à jour** : `platform_channel_test.dart`, `purchasely_flutter_test.dart`, + `bridge_test.dart`, `native_view_widget_test.dart`, `transition_test.dart`, + `dart_android_bridge_test.dart`, `default_dismiss_handler_test.dart`, + `interceptor_trigger_test.dart` — tous les types renommés, les assertions + sur les valeurs obsolètes de `PLYRunningMode` corrigées. + +**Résultat** : `flutter analyze` → 0 erreur, `flutter test` → 225 tests ✅. + +--- + +## 3. API Dart v6 finale (référence pour `../Documentation` + `../purchasely-ai-skill`) + +### Initialisation + +```dart +final bool configured = await Purchasely.apiKey('') + .appUserId('user_id') // optionnel + .runningMode(PLYRunningMode.full) // observer (défaut) | full + .logLevel(PLYLogLevel.error) // debug | info | warn | error + .allowDeeplink(true) + .allowCampaigns(true) // optionnel + .stores([PLYStore.google]) // Android : google | huawei | amazon + .storekitVersion(PLYStorekitVersion.storeKit2) // iOS : storeKit2 (défaut) | storeKit1 + .start(); +``` + +> **Le mode par défaut est `observer`** en v6. Passer `.runningMode(PLYRunningMode.full)` +> si Purchasely doit gérer/valider les achats. + +### Affichage d'une présentation + +```dart +final outcome = await PLYPresentationBuilder.placement('') + .contentId('content_id') // optionnel + .onLoaded((p, err) {}) // optionnel + .onPresented((p, err) {}) // optionnel + .onCloseRequested(() {}) // optionnel + .onDismissed((o) {}) // optionnel + .build() + .display(const PLYTransition.fullScreen()); // fullScreen | modal | push | drawer | popin + +// PLYPresentationOutcome (5 champs) : +// presentation, purchaseResult, plan (PLYPlan?), closeReason, error +``` + +Autres sources : `PLYPresentationBuilder.screen('')`, +`PLYPresentationBuilder.defaultSource()`. Cycle de vie : +`request.preload()` → `PLYPresentation` (avec `.display()`, `.close()`, `.back()`). + +Pattern chaîné (preload + display en une expression) : + +```dart +final outcome = await PLYPresentationBuilder.placement('') + .build() + .preload() + .display(const PLYTransition.drawer(height: PLYTransitionDimension.percentage(0.5))); +``` + +### Transitions dimensionnées + +Constructeurs nommés disponibles sur `PLYTransition` : + +| Constructeur | Description | +|---|---| +| `PLYTransition.fullScreen()` | Plein écran | +| `PLYTransition.modal({bool? dismissible})` | Modal sheet | +| `PLYTransition.push()` | Push / navigation | +| `PLYTransition.drawer({PLYTransitionDimension? height, bool? dismissible, PLYTransitionColors? backgroundColors})` | Drawer bas | +| `PLYTransition.popin({PLYTransitionDimension? width, PLYTransitionDimension? height, …})` | Pop-in flottant | + +`PLYTransitionDimension` : `.pixel(value)` ou `.percentage(value)` (0.0–1.0). + +### Action interceptor + +```dart +await Purchasely.interceptAction(PLYPresentationActionKind.purchase, (info, payload) async { + if (payload is PLYPurchasePayload) { /* … */ } + return PLYInterceptResult.notHandled; // success | failed | notHandled +}); +await Purchasely.removeActionInterceptor(PLYPresentationActionKind.purchase); +await Purchasely.removeAllActionInterceptors(); +``` + +Kinds : `close, closeAll, login, navigate, purchase, restore, openPresentation, +openPlacement, promoCode, webCheckout`. Payloads typés : `PLYNavigatePayload`, +`PLYPurchasePayload`, `PLYClosePayload`, `PLYCloseAllPayload`, +`PLYOpenPresentationPayload`, `PLYOpenPlacementPayload`, `PLYWebCheckoutPayload`. + +### Inline (embarqué) + +```dart +final request = PLYPresentationBuilder.placement('inline').onDismissed((o) {}).build(); +PLYPresentationView(request: request); // dans le widget tree +``` + +### Synchronize (nouveau comportement) + +```dart +try { + await Purchasely.synchronize(); // résout à la fin ; lève en cas d'échec +} catch (e) { /* PlatformException */ } +``` + +### Inchangé (source-compatible) + +`purchaseWithPlanVendorId`, `signPromotionalOffer`, `restoreAllProducts`, +`silentRestoreAllProducts`, `userLogin`/`userLogout`, `isAnonymous`, +`anonymousUserId`, `allProducts`, `productWithIdentifier`, `planWithIdentifier`, +`isEligibleForIntroOffer`, `userSubscriptions`/`userSubscriptionsHistory`, +`setUserAttribute*` (+ increment/decrement/clear), `listenToEvents`/`listenToPurchases`, +`setDynamicOffering`/`getDynamicOfferings`/…, `revokeDataProcessingConsent`, +`setLanguage`, `setThemeMode`, `setLogLevel`, `setDebugMode`, +`allowDeeplink`/`handleDeeplink` (+ alias dépréciés `readyToOpenDeeplink`/`isDeeplinkHandled`). + +No-op v6 (UI native supprimée) : `displaySubscriptionCancellationInstruction`. +(`presentSubscriptions` a été **retiré** — cf. §2.5.) + +--- + +## 4. Contrat de canal (Dart ↔ natif) + +- **MethodChannel `purchasely`** : `start`, `preload`, `display`, `close`, `back`, + `registerInterceptor`, `removeInterceptor`, `removeAllInterceptors`, + `interceptorResolve`, `synchronize`, + toute la surface conservée. +- **EventChannel `purchasely-presentation-events`** : `onLoaded`, `onPresented`, + `onCloseRequested`, `onDismissed`, `interceptorTriggered` (chaque enveloppe porte + un `requestId`). +- EventChannels existants : `purchasely-events`, `purchasely-purchases`, + `purchasely-user-attributes`. + +--- + +## 5. Vérifications exécutées (preuves) + +| Vérification | Commande | Résultat | +|---|---|---| +| Dart analyze | `flutter analyze` | ✅ clean | +| Dart tests | `flutter test` | ✅ (suite complète, dont nouveaux tests `synchronize`) | +| Build Android | `flutter build apk --debug` (résout `6.0.0-rc.1` depuis **Maven Central**, sans mavenLocal) | ✅ `app-debug.apk` | +| Tests unit Android | `./gradlew :purchasely_flutter:testDebugUnitTest` | ✅ BUILD SUCCESSFUL | +| Build iOS | `xcodebuild … build` (`Purchasely 6.0.0-rc.1` depuis le **CocoaPods trunk**, sans dev-pod) | ✅ BUILD SUCCEEDED | +| Tests unit iOS | `xcodebuild test -only-testing:RunnerTests` (iPhone 17, iOS 26.5) | ✅ Executed 9 tests, 0 failures | +| Smoke iOS (réel, iPhone 17) | `flutter run` | ✅ SDK démarré, `Anonymous Id`, `is eligible: true`, `Product found`, dynamic offerings — backend réel ; UI rendue | +| Smoke Android (réel, Pixel_Tablet) | install APK + launch | ✅ `Initialization done`, `isSdkStarted=true`, `USER_LOGGED_IN userId=MY_USER_ID`, `Product found` — exemple Flutter, backend réel | +| **Présentation v6 de bout en bout (Android)** | tap « Display presentation » (placement `STRIPE`) | ✅ `PRESENTATION_LOADED` (type NORMAL) → `PRESENTATION_VIEWED` → **paywall `stripe_test` affiché plein écran** (0 crash) | + +> Le run Flutter Android a d'abord buté sur `INSTALL_FAILED_INSUFFICIENT_STORAGE` +> (1er émulateur saturé par des apps utilisateur) puis a été finalisé sur le +> Pixel_Tablet. Le test d'affichage a révélé et permis de corriger le crash +> `PLYTransition` (conflit de version, cf. §2.4). + +--- + +## 6. Fichiers modifiés (cette session) + +- `purchasely/lib/purchasely_flutter.dart` — doc + sémantique `synchronize`. +- `purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift` — `from(screenId:)`, + `PLYPresentationOutcome()`, `synchronize` callbacks, `presentSubscriptions` retiré, + mapping `closeReason`, transitions modernes. +- `purchasely/ios/Classes/NativeView.swift` — `PLYPresentationOutcome()`. +- `purchasely/ios/purchasely_flutter.podspec` — pin `Purchasely 6.0.0-rc.1`. +- `purchasely/android/.../PurchaselyFlutterPlugin.kt` — `synchronize` callbacks, + `PLYTransition` (args nommés + `PLYTransitionDimension`), imports. +- `purchasely/android/build.gradle`, `purchasely_google/android/build.gradle`, + `purchasely_android_player/android/build.gradle` — pin `6.0.0-rc.1`. +- `purchasely/example/android/build.gradle` — `mavenLocal()` retiré. +- `purchasely/example/android/app/build.gradle` — pin `6.0.0-rc.1`. +- `purchasely/example/ios/Podfile` (+ `Podfile.lock`) — dev-pod retiré, résout + `Purchasely 6.0.0-rc.1` depuis le trunk (plus de chemin absolu → commitable). +- `purchasely/test/platform_channel_test.dart` — tests `synchronize`. +- `purchasely/android/.../PurchaselyFlutterPluginTest.kt` — test `synchronize`. +- `purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift` — recréé. +- `purchasely/example/lib/main.dart` — exemple `synchronize`. +- `MIGRATION-v6.md`, `V6_MIGRATION_REPORT.md` — docs. + +**Édition cross-repo** : `/Users/kevin/Purchasely/iOS/Purchasely.podspec` avait été +bumpé pour le dev-pod ; **revert à `3.6.2`** car le dev-pod n'est plus utilisé (résolution +depuis le trunk). + +--- + +## 7. Doutes / points à reviewer (À LIRE) + +1. **Version native (RÉSOLU).** Pin = **`6.0.0-rc.1`** (avec point) sur les deux + plateformes, **réellement publié** : Android sur Maven Central + (`repo1.maven.org/.../io/purchasely/core/6.0.0-rc.1/` → HTTP 200), iOS sur le + CocoaPods trunk (`pod trunk info Purchasely` → `6.0.0-rc.1`, publié le 12/06/2026). + Le projet builde donc depuis les dépôts publics (mavenLocal + dev-pod retirés) → + le CI natif devrait passer. **Seul reste à trancher (release)** : `6.0.0` (GA) est + sur Maven Central mais **pas encore sur CocoaPods trunk** — donc `6.0.0-rc.1` est + la seule version cohérente cross-plateforme publiée aujourd'hui. Bumper vers + `6.0.0` quand le pod GA sortira. + +2. **Version du plugin Flutter (RÉSOLU).** Alignée sur le pré-release natif : + `6.0.0-rc.1` (pubspecs des 3 packages, podspec, `sdkBridgeVersion` Kotlin/Swift, + CHANGELOGs, VERSIONS.md, READMEs, `sdk_public_doc.md`). Bumper vers `6.0.0` en + même temps que les natifs au GA. + +3. **Podspec iOS local (RÉSOLU).** Le dev-pod `:path` a été retiré : iOS résout + `Purchasely 6.0.0-rc.1` depuis le trunk. Le Podfile n'a plus de chemin absolu et + est donc commité. (Le bump cross-repo du podspec iOS a été reverté.) + +4. **Cohérence des versions natives `io.purchasely:*` (vérifier au merge).** Le + crash `PLYTransition` venait d'un `6.0.0` perdu dans l'exemple. Avant merge, + `grep -rn "io.purchasely:.*6\.0\.0\b" *` pour s'assurer qu'aucune référence ne + pointe une autre version que `6.0.0-rc.1`. Idem quand la version finale sortira. + +5. **`signPromotionalOffer` côté Android.** Non géré dans le `when` du bridge Android + (renvoie `notImplemented`) — comportement pré-existant (offres promo Apple = iOS). + Pas dans le scope de la migration, mais à confirmer si une parité est attendue. + +6. **`contentId` de présentation chargée sur iOS.** Toujours `null` côté iOS + (`PLYPresentation` ne l'expose pas en natif). Android le renvoie. Documenté. + +7. **Warning iOS résiduel.** `setThemeMode` est déprécié côté natif (« removed in + v7.0 »), mais conservé car méthode v5 toujours fonctionnelle. À remplacer par le + modifier `.themeMode()` du builder lors d'une future passe. + +--- + +## 8. Pour mettre à jour `../Documentation` et `../purchasely-ai-skill` + +- `purchasely-ai-skill/references/flutter/integration.md` : encore en **v5** + (`Purchasely.start(...)`, `fetchPresentation`/`presentPresentation`, + `setPaywallActionInterceptorCallback` + `onProcessAction`). À remplacer par + l'API v6 (§3) : `Purchasely.apiKey(…)`, `PLYPresentationBuilder` / + `PLYPresentationRequest`, `Purchasely.interceptAction`, `PLYPresentationView`, + `synchronize` awaitable. **Tous les types doivent porter le préfixe `PLY`** + (cf. §2.9 — BREAKING depuis le 2026-06-24). +- Créer `purchasely-ai-skill/references/flutter/migration-v6.md` (analogue + Android/iOS) à partir de `MIGRATION-v6.md` (déjà à jour avec les noms PLY). +- `purchasely-ai-skill/references/sdk-versions.md` : Flutter passe de `5.7.3` à + `6.0.0-rc.1` (plugin), natifs `6.0.0-rc.1`. +- Docs publiques (`../Documentation`) : guide d'intégration Flutter + guide de + migration 5→6 Flutter, en miroir des guides Android/iOS. Utiliser les noms + PLY-préfixés de §3 et `MIGRATION-v6.md`. diff --git a/VERSIONS.md b/VERSIONS.md index b7a31ec6..d12b18e5 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -1,7 +1,8 @@ -This file provides the underlying native SDK versions that the React Native SDK relies on. +This file provides the underlying native SDK versions that the Flutter SDK relies on. | Version | iOS version | Android version | |---------|-------------|-----------------| +| 6.0.0-rc.1 | 6.0.0-rc.1 | 6.0.0-rc.1 | | 4.0.0 | 4.0.0 | 4.0.0 | | 4.0.1 | 4.0.1 | 4.0.0 | | 4.0.2 | 4.0.3 | 4.0.0 | @@ -50,3 +51,4 @@ This file provides the underlying native SDK versions that the React Native SDK | 5.7.1 | 5.7.1 | 5.7.1 | | 5.7.2 | 5.7.2 | 5.7.3 | | 5.7.3 | 5.7.4 | 5.7.4 | +| 6.0.0-rc.1 | 6.0.0-rc.1 | 6.0.0-rc.1 | diff --git a/docs/superpowers/plans/2026-06-24-v6-api-native-alignment.md b/docs/superpowers/plans/2026-06-24-v6-api-native-alignment.md new file mode 100644 index 00000000..d8f96116 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-v6-api-native-alignment.md @@ -0,0 +1,139 @@ +# v6 Public API ↔ Native Truth Alignment — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Align the Purchasely Flutter v6 public API with the authoritative native SDKs (Android v6.0.0-rc.2 `develop`, iOS v6): rename `PresentationOutcome`→`PLYPresentationOutcome`, type `outcome.plan` as `PLYPlan?`, reduce `CloseReason` to `{button, backSystem, programmatic}`, rename interceptor cleanup APIs, make `synchronize()` return `Future`, and replace `Transition.heightPercentage` with the Android dimension model (`width`/`height` as `PLYTransitionDimension`). + +**Architecture:** Pure-Dart façade over MethodChannel/EventChannel bridging native iOS (Swift) and Android (Kotlin) plugins. v6 is BREAKING — no legacy compat. Wire verbs (`removeInterceptor`, `removeAllInterceptors`, `synchronize`, `display`) stay unchanged; only Dart public names and serialization shapes change. + +**Tech Stack:** Dart/Flutter, Swift (CocoaPods), Kotlin (Gradle). + +## Global Constraints + +- BREAKING v6 — do NOT keep legacy aliases or `heightPercentage`. +- Native truth is fixed: + - iOS `PLYCloseReason.rawDescription`: `none`→"none", `button`→"button", `interactiveDismiss`→"back_system", `programmatic`→"programmatic". + - iOS `PLYDimension`: `case value(Int)` (pixels), `case percentage(Float)`. iOS dimension factories: `PLYDisplayMode.drawer(height: PLYDimension?, dismissible:)`, `.popin(width: PLYDimension?, height: PLYDimension?, dismissible:)`. + - Android `PLYTransition(type, width: PLYTransitionDimension?, height: PLYTransitionDimension?, [heightPercentage @Deprecated — DO NOT SET], backgroundColors, dismissible=true)`; `PLYTransitionDimension(type: PLYDimensionType=PERCENTAGE, value: Float)`; `PLYDimensionType { PIXEL("pixel"), PERCENTAGE("percentage") }`. + - Android `outcome.closeReason?.value` and `synchronize(onSuccess,onError)` already wired natively. +- Dart wire contract for a transition dimension: `{ 'type': 'pixel'|'percentage', 'value': double }`; omit null width/height. +- Verification gate: `cd purchasely && flutter analyze` (0 issues) AND `flutter test` (0 failures). + +--- + +### Task 1: PLYPresentationOutcome model — rename, typed plan, reduced CloseReason + +**Files:** +- Modify: `purchasely/lib/src/presentation_outcome.dart` +- Test: `purchasely/test/bridge_test.dart` + +**Interfaces:** +- Produces: `class PLYPresentationOutcome { Presentation? presentation; PurchaseResult? purchaseResult; PLYPlan? plan; CloseReason? closeReason; PresentationError? error; }`; `enum CloseReason { button, backSystem, programmatic }`; `CloseReason? closeReasonFromString(String?)`. + +Steps: +- [ ] Add `import 'ply_models.dart';` (for `PLYPlan`). +- [ ] Rename `class PresentationOutcome` → `class PLYPresentationOutcome` (ctor + toString). +- [ ] Change field `Map? plan;` → `PLYPlan? plan;`. +- [ ] Reduce enum to `enum CloseReason { button, backSystem, programmatic }` (remove `interactiveDismiss`). +- [ ] Rewrite `closeReasonFromString`: `'button'`→button, `'back_system'`→backSystem, `'programmatic'`→programmatic, default→null (drop `interactiveDismiss`/`interactive_dismiss`/`backSystem` camel cases). +- [ ] Run `flutter test test/bridge_test.dart` — expect compile failures elsewhere (handled in later tasks). This task's correctness is gated by the full-suite run in Task 9. + +### Task 2: Transition — dimension model replaces heightPercentage + +**Files:** +- Modify: `purchasely/lib/src/transition.dart` +- Test: `purchasely/test/transition_test.dart` (Create) + +**Interfaces:** +- Produces: `enum PLYDimensionType { pixel, percentage }`; `class PLYTransitionDimension { PLYDimensionType type; double value; PLYTransitionDimension.pixel(double); PLYTransitionDimension.percentage(double); Map toMap(); }`; `class Transition { TransitionType type; PLYTransitionDimension? width; PLYTransitionDimension? height; bool? dismissible; TransitionColors? backgroundColors; Transition.fullScreen(); Transition.modal({dismissible}); Transition.push(); Map toMap(); }`. + +Steps: +- [ ] Add `enum PLYDimensionType { pixel, percentage }`. +- [ ] Add `class PLYTransitionDimension` with `final PLYDimensionType type; final double value;` const generic ctor + `const PLYTransitionDimension.pixel(this.value): type = PLYDimensionType.pixel;` + `const PLYTransitionDimension.percentage(this.value): type = PLYDimensionType.percentage;` and `Map toMap() => {'type': type == PLYDimensionType.pixel ? 'pixel' : 'percentage', 'value': value};`. +- [ ] On `Transition`: remove `heightPercentage`; add `final PLYTransitionDimension? width;` and `final PLYTransitionDimension? height;`. Keep `type`, `dismissible`, `backgroundColors`. Update generic ctor to `const Transition({required this.type, this.width, this.height, this.dismissible, this.backgroundColors});`. Keep `Transition.fullScreen()`, `Transition.modal({bool? dismissible})`, `Transition.push()`. +- [ ] `toMap()`: keep `'type': _typeToWire(type)`; add `if (width != null) 'width': width!.toMap()`, `if (height != null) 'height': height!.toMap()`; keep `dismissible`/`backgroundColors`. Remove the `heightPercentage` entry. +- [ ] Write `test/transition_test.dart`: `Transition.modal()` toMap has `type=modal`; a `Transition(type: TransitionType.popin, width: PLYTransitionDimension.pixel(320), height: PLYTransitionDimension.percentage(0.5))` toMap serializes `width={'type':'pixel','value':320.0}`, `height={'type':'percentage','value':0.5}`; null dimensions omitted. + +### Task 3: Bridge — type rename, typed plan parse, interceptor cleanup rename + +**Files:** +- Modify: `purchasely/lib/src/bridge.dart` + +Steps: +- [ ] Add `import 'ply_transformers.dart';` (for `plyPlanFromMap`). +- [ ] `replace_all` `PresentationOutcome` → `PLYPresentationOutcome`. +- [ ] In `_outcomeFromMap`, replace the `Map? plan` block with `final plan = plyPlanFromMap(raw['plan'] is Map ? raw['plan'] as Map : null);` and pass `plan: plan`. +- [ ] Rename method `removeInterceptor` → `removeActionInterceptor` (keep `invokeMethod('removeInterceptor', ...)` wire verb). +- [ ] Rename method `removeAllInterceptors` → `removeAllActionInterceptors` (keep `invokeMethod('removeAllInterceptors')` wire verb). + +### Task 4: Public API surface — purchasely_flutter.dart + +**Files:** +- Modify: `purchasely/lib/purchasely_flutter.dart` + +Steps: +- [ ] `replace_all` `PresentationOutcome` → `PLYPresentationOutcome` (import line + doc + handler type). +- [ ] Rename `static Future removeInterceptor(...)` → `removeActionInterceptor(...)` delegating to `bridge.removeActionInterceptor(kind)`. +- [ ] Rename `static Future removeAllInterceptors()` → `removeAllActionInterceptors()` delegating to `bridge.removeAllActionInterceptors()`. +- [ ] Change `synchronize`: `static Future synchronize() async { final ok = await _channel.invokeMethod('synchronize'); return ok == true; }` (PlatformException already propagates on native error). + +### Task 5: Remaining lib refs + +**Files:** +- Modify: `purchasely/lib/src/presentation.dart`, `presentation_builder.dart`, `presentation_request.dart`, `native_view_widget.dart` + +Steps: +- [ ] `replace_all` `PresentationOutcome` → `PLYPresentationOutcome` in each (includes doc comments + `native_view_widget.dart` comment). + +### Task 6: Tests update + +**Files:** +- Modify: `purchasely/test/bridge_test.dart`, `purchasely/test/platform_channel_test.dart` + +Steps: +- [ ] bridge_test: `PresentationOutcome? captured;` → `PLYPresentationOutcome? captured;`. +- [ ] bridge_test default-dismiss test: change event `'closeReason': 'interactiveDismiss'` → `'back_system'`; assertion `CloseReason.interactiveDismiss` → `CloseReason.backSystem`; `captured!.plan?['vendorId']` → `captured!.plan?.vendorId`. +- [ ] bridge_test 5-field test (already sends `'plan': {'vendorId':'monthly'}`): keep `outcome.plan, isNotNull`; optionally assert `outcome.plan!.vendorId == 'monthly'`. +- [ ] bridge_test removeInterceptor test: call `.removeActionInterceptor(...)`; keep wire assertion `c.method == 'removeInterceptor'`. +- [ ] Add a bridge_test case: emit onDismissed with full plan map (`vendorId`, `productId`, `basePlanId`, `amount`, `currencyCode`…) and assert typed fields on `outcome.plan!` (vendorId/productId/basePlanId). +- [ ] platform_channel_test: add `expect(await Purchasely.synchronize(), isTrue);` where the mock returns true for `synchronize`. + +### Task 7: Native iOS plugin + +**Files:** +- Modify: `purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift` + +Steps: +- [ ] `outcomeToMap`: replace partial `planMap` with `planMap = plan.toMap` (full `PLYPlan+ToMap.swift` extension). +- [ ] `outcomeToMap` closeReason: `switch outcome.closeReason { case .none: return nil; default: return outcome.closeReason.rawDescription }` (drop the `.interactiveDismiss → "interactiveDismiss"` special case; `.interactiveDismiss` now yields `"back_system"`). +- [ ] `parseTransition`: add `private static func parseDimension(_ raw: Any?) -> PLYDimension?` that reads `["type","value"]` → `.value(Int(v.rounded()))` for `"pixel"`, `.percentage(Float(v))` for `"percentage"` (default percentage). Replace the `heightPercentage` reads with `let height = parseDimension(map["height"])`, `let width = parseDimension(map["width"])`. `drawer`→`.drawer(height: height, dismissible:)`; `popin`→`.popin(width: width, height: height, dismissible:)`. Keep fullScreen/push/modal/inlinePaywall. + +### Task 8: Native Android plugin + +**Files:** +- Modify: `purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt` + +Steps: +- [ ] `outcomeToMap` (Companion): replace partial inline `plan` map with `"plan" to outcome.plan?.let { transformPlanToMap(it) }` (full serialization, type ordinal). NOTE: `transformPlanToMap` is `private` in Companion — `outcomeToMap` is also in Companion, so direct call is fine. +- [ ] `parseTransition`: add `parseDimension(map["width"] as? Map<*, *>)` / `parseDimension(map["height"] as? Map<*, *>)` returning `PLYTransitionDimension(PLYDimensionType, Float)` (`"pixel"`→PIXEL else PERCENTAGE). Build `PLYTransition(type = type, width = width, height = height, dismissible = dismissible)`. Remove `heightPercentage` read (do NOT set the deprecated arg). + +### Task 9: Docs + verification + +**Files:** +- Modify: `MIGRATION-v6.md` +- Run: `cd purchasely && flutter analyze && flutter test` + +Steps: +- [ ] MIGRATION-v6.md: `replace_all` `PresentationOutcome` → `PLYPresentationOutcome`; `Purchasely.removeInterceptor` → `removeActionInterceptor`; `Purchasely.removeAllInterceptors` → `removeAllActionInterceptors`; update synchronize note from `Future` → `Future` (resolves `true`, throws `PlatformException`). Add/keep Transition dimension example using `PLYTransitionDimension.percentage(...)`. +- [ ] Example app: `presentation_demo_screen.dart` + `presentation_screen.dart`: `PresentationOutcome` → `PLYPresentationOutcome`. `main.dart` synchronize comment stays valid (now returns bool). +- [ ] `flutter analyze` → 0 issues. `flutter test` → 0 failures. + +## Self-Review + +- Spec item 1 (rename) → Tasks 1,3,4,5,6,9. ✓ +- Spec item 2 (typed plan + full native serialization) → Tasks 1,3,6,7,8. ✓ +- Spec item 3 (CloseReason reduce, iOS rawDescription, Android `.value`, Dart parser) → Tasks 1,7 (Android already `?.value`). ✓ +- Spec item 4 (interceptor cleanup rename, Dart only) → Tasks 3,4,6,9. ✓ +- Spec item 5 (synchronize Future) → Tasks 4,6 (native already wired). ✓ +- Spec item 6 (Transition dimension model) → Tasks 2,7,8,9. ✓ +- Spec items 7 (defaultSource) & 8 (deeplink) → no change. ✓ diff --git a/purchasely/CHANGELOG.md b/purchasely/CHANGELOG.md index 649cf27f..91bd1606 100644 --- a/purchasely/CHANGELOG.md +++ b/purchasely/CHANGELOG.md @@ -1,3 +1,55 @@ +## 6.0.0-rc.1 + +- **Adapts the plugin to the Purchasely 6.0 native SDKs.** The breaking changes + are limited to the paywall surface: **starting the SDK**, **displaying / + preloading / closing a presentation**, and the **action interceptor**. Other + `Purchasely` APIs remain source-compatible; deeplinks now expose the v6 names + (`allowDeeplink`, `handleDeeplink`) and removes the old v5 aliases. See + `MIGRATION-v6.md` for the complete old→new mapping. +- **Start.** The SDK is now started with the fluent builder + `PurchaselyBuilder.apiKey(...).appUserId(...).runningMode(...).logLevel(...).allowDeeplink(...).allowCampaigns(...).stores([...]).storekitVersion(...).start()`. +- **Presentation.** Build a request with `PresentationBuilder` + (`.placement(id)` / `.screen(id)` / `.defaultSource()`) plus + `.contentId(...).onLoaded(...).onPresented(...).onCloseRequested(...).onDismissed(...).build()`, + then drive its lifecycle: + - `PresentationRequest.preload()` fetches the screen without displaying it. + - `PresentationRequest.display([Transition])` shows it and resolves at + **dismiss time** with the 5-field `PLYPresentationOutcome` + (`presentation`, `purchaseResult`, `plan` (typed `PLYPlan?`), `closeReason`, + `error`). `closeReason` is `button` / `backSystem` / `programmatic`. + `Transition` sizes `drawer`/`popin` via `width`/`height` + (`PLYTransitionDimension.percentage(...)` / `.pixel(...)`). + - A loaded `Presentation` exposes `display()`, `close()` and `back()` for + programmatic control. + - Inline (embedded) rendering uses the `PLYPresentationView` widget. +- **Action interceptor.** Replaced by + `Purchasely.interceptAction(PresentationActionKind, handler)` (plus + `removeActionInterceptor` / `removeAllActionInterceptors`). The handler receives a typed + `ActionPayload` (e.g. `NavigatePayload`, `PurchasePayload`) and returns an + `InterceptResult` (`success` / `failed` / `notHandled`) — there is no more + `onProcessAction`. `PurchasePayload` exposes real objects (`PLYPlan`, + `PLYSubscriptionOffer?`, `PLYPromoOffer?`) instead of raw maps. +- **Behaviour — running mode default.** The 6.0 native SDKs default to + **Observer** mode (was Full). The builder mirrors this default + (`RunningMode.observer`); pass `.runningMode(RunningMode.full)` to keep the + previous Full behaviour. +- **BREAKING — removed `presentSubscriptions()`.** The native subscriptions + screen was removed from the 6.0 SDKs (both Android and iOS — the iOS + `subscriptionsController()` entry point no longer exists), so + `Purchasely.presentSubscriptions()` has been **removed entirely** from the + Flutter API on every layer (Dart, iOS, Android). It is no longer a no-op — the + method no longer exists. Build your own subscriptions screen from + `userSubscriptions()` / `userSubscriptionsHistory()`. +- **Behaviour — `displaySubscriptionCancellationInstruction()` is a no-op.** The + cancellation survey UI was removed from the 6.0 SDKs, so this method is a no-op + on both Android and iOS (kept for source compatibility). +- **Native SDK bump.** + - iOS: `Purchasely 6.0.0-rc.1` (was 5.7.4). + - Android: `io.purchasely:core 6.0.0-rc.1` (was 5.7.4). + - Both pre-releases are published on public repositories — Android on Maven + Central, iOS on the CocoaPods trunk — so the SDK resolves them with no + `mavenLocal()` and no development pod. + ## 5.7.3 - Updated iOS Purchasely SDK to 5.7.4. - Updated Android Purchasely Core SDK to 5.7.4. diff --git a/purchasely/README.md b/purchasely/README.md index 9f316eb0..bd7d7dd3 100644 --- a/purchasely/README.md +++ b/purchasely/README.md @@ -4,11 +4,16 @@ Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery. +> **Upgrading to 6.0?** The paywall surface (start, display/preload/close, action +> interceptor) moved to a fluent builder API; other `Purchasely` APIs remain +> source-compatible (deeplinks use v6 names). See +> [`MIGRATION-v6.md`](../MIGRATION-v6.md) for the complete old→new mapping. + ## Installation -``` +```yaml dependencies: - purchasely_flutter: ^5.1.0 + purchasely_flutter: 6.0.0-rc.1 ``` ## Usage @@ -16,37 +21,71 @@ dependencies: ```dart import 'package:purchasely_flutter/purchasely_flutter.dart'; -// ... - -bool configured = await Purchasely.start( - apiKey: '', - androidStores: ['Google, Huawei, Amazon'], - storeKit1: false, - logLevel: PLYLogLevel.error, - runningMode: PLYRunningMode.full, - userId: null, -); - -var result = await Purchasely.presentPresentationForPlacement("", isFullscreen: true); - -switch (result.result) { - case PLYPurchaseResult.cancelled: - { - print("User cancelled purchased"); - } - break; - case PLYPurchaseResult.purchased: - { - print("User purchased ${result.plan?.name}"); - } - break; - case PLYPurchaseResult.restored: - { - print("User restored ${result.plan?.name}"); - } - break; +// 1. Start the SDK (fluent builder, `start()` returns once configured). +await PurchaselyBuilder.apiKey('') + .runningMode(RunningMode.observer) + .logLevel(LogLevel.error) + .stores([PLYStore.google]) + .start(); + +// 2. Build a presentation request and display it. +// `.display(...)` resolves at *dismiss* time with the enriched 5-field +// `PLYPresentationOutcome` (presentation, purchaseResult, plan, closeReason, +// error). +final outcome = await PresentationBuilder + .placement('') + .contentId('article-42') + .onLoaded((presentation, error) => print('loaded ${presentation.screenId}')) + .onPresented((presentation, error) => print('shown')) + .onDismissed((o) => print('dismissed: ${o.purchaseResult}')) + .build() + .display(const Transition.modal()); + +switch (outcome.purchaseResult) { + case PurchaseResult.cancelled: + print('User cancelled'); + break; + case PurchaseResult.purchased: + print('User purchased ${outcome.plan}'); + break; + case PurchaseResult.restored: + print('User restored ${outcome.plan}'); + break; + case null: + print('Dismissed without purchase action'); + break; } ``` +## Migration to 6.0 + +This release adapts the plugin to the Purchasely 6.0 native SDKs. Only the +paywall surface has breaking changes; other `Purchasely` APIs remain +source-compatible. + +| Old (`Purchasely.*`) | New | +|---|---| +| `Purchasely.start(apiKey: ..., runningMode: PLYRunningMode.full)` | `PurchaselyBuilder.apiKey(...).runningMode(RunningMode.full).start()` | +| `Purchasely.presentPresentationForPlacement(id, isFullscreen: true)` | `PresentationBuilder.placement(id).build().display(const Transition.fullScreen())` | +| `Purchasely.fetchPresentation(...)` | `PresentationBuilder.placement(id).build().preload()` | +| `result.result` (3-value enum), `result.plan` | `outcome.presentation`, `outcome.purchaseResult`, `outcome.plan`, `outcome.closeReason`, `outcome.error` | +| `Purchasely.setPaywallActionInterceptorCallback(...)` + `onProcessAction(bool)` | `Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async => InterceptResult.notHandled)` | + +See [`MIGRATION-v6.md`](../MIGRATION-v6.md) for the full old→new mapping and +before/after examples. + +### Platform limitations in this beta + +- **Removed (BREAKING): `presentSubscriptions()`.** The 6.0 native SDKs removed + the built-in subscriptions list on both Android and iOS, so + `Purchasely.presentSubscriptions()` no longer exists. Build your own UI with + `userSubscriptions()` / `userSubscriptionsHistory()`. +- The cancellation survey UI was also removed, so + `Purchasely.displaySubscriptionCancellationInstruction()` is a no-op on both + platforms. +- iOS v6 currently does not expose `closeReason` or a loaded presentation + `contentId` on `PLYPresentation`; Flutter reports those fields as `null` on + iOS instead of inventing values. + ## 🏁 Documentation A complete documentation is available on our website [https://docs.purchasely.com](https://docs.purchasely.com) \ No newline at end of file diff --git a/purchasely/android/build.gradle b/purchasely/android/build.gradle index 4b749279..7ebf8de9 100644 --- a/purchasely/android/build.gradle +++ b/purchasely/android/build.gradle @@ -2,7 +2,7 @@ group 'io.purchasely.purchasely_flutter' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '2.1.21' + ext.kotlin_version = '2.3.21' repositories { google() mavenCentral() @@ -16,6 +16,7 @@ buildscript { rootProject.allprojects { repositories { + mavenLocal() // PURCHASELY v6.0.0 — remove once published to Maven Central google() mavenCentral() } @@ -58,14 +59,17 @@ dependencies { api 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0' - api 'io.purchasely:core:5.7.4' + // Purchasely 6.0 native SDK — provides the builder/interceptAction/PLYPresentationBase + // APIs wired by the single plugin (PurchaselyFlutterPlugin.kt), which compiles the + // whole surface against this version. + api 'io.purchasely:core:6.0.0-rc.2' // Test dependencies testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.21.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0' - testImplementation 'io.mockk:mockk:1.14.9' - testImplementation 'io.mockk:mockk-agent-jvm:1.14.9' - testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2' + testImplementation 'io.mockk:mockk:1.14.11' + testImplementation 'io.mockk:mockk-agent-jvm:1.14.11' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0' testImplementation 'org.robolectric:robolectric:4.11.1' } diff --git a/purchasely/android/gradle/wrapper/gradle-wrapper.properties b/purchasely/android/gradle/wrapper/gradle-wrapper.properties index a3c498af..7a04a2dc 100644 --- a/purchasely/android/gradle/wrapper/gradle-wrapper.properties +++ b/purchasely/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/purchasely/android/src/main/AndroidManifest.xml b/purchasely/android/src/main/AndroidManifest.xml index fffbc8c9..c3a00009 100644 --- a/purchasely/android/src/main/AndroidManifest.xml +++ b/purchasely/android/src/main/AndroidManifest.xml @@ -7,9 +7,4 @@ android:name="android.hardware.touchscreen" android:required="false" /> - - - - - diff --git a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt index ed6c191f..496f089a 100644 --- a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt +++ b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt @@ -4,19 +4,12 @@ import android.content.Context import android.util.Log import android.view.View import android.widget.FrameLayout -import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.platform.PlatformView -import io.purchasely.ext.PLYPresentationProperties -import io.purchasely.ext.PLYProductViewResult -import io.purchasely.ext.Purchasely -import io.purchasely.models.PLYPresentationPlan -import io.purchasely.models.PLYPlan internal class NativeView( context: Context, id: Int, creationParams: Map?, - private val methodChannel: MethodChannel ) : PlatformView { private val layout: FrameLayout @@ -29,87 +22,33 @@ internal class NativeView( init { layout = FrameLayout(context) - val presentationId = creationParams?.get("presentationId") as? String - val placementId = creationParams?.get("placementId") as? String - val presentationMap = creationParams?.get("presentation") as? Map - val presentation = PurchaselyFlutterPlugin.presentationsLoaded.lastOrNull { - it.id == presentationMap?.get("id") as? String - && it.placementId == presentationMap?.get( - "placementId" - ) as? String - } - - if (presentation != null) { - Log.d("Purchasely", "PLYPresentation found: ${presentation}") - // Build the presentation view - val presentationView = presentation.buildView( - context = context, - properties = PLYPresentationProperties( - onClose = { closeCallback() } - ), - callback = { result, plan -> - methodChannel.invokeMethod( - "onPresentationResult", mapOf( - "result" to result.ordinal, - "plan" to plan?.toMap(), - ) - ) - } - ) + // The inline native view is built from a Presentation that was already + // loaded (via `preload`) and is keyed by the Dart requestId. + val requestId = creationParams?.get("requestId") as? String + val presentation = requestId?.let { PurchaselyFlutterPlugin.loadedPresentations[it] } + + if (requestId != null && presentation != null) { + Log.d("Purchasely", "Loaded Presentation found for requestId=$requestId") + + val presentationView = presentation.buildView(context) { outcome -> + // Surface the embedded outcome through the SAME presentation-events + // sink and envelope shape as the full-screen path, keyed by the + // request's `requestId`, so the Dart `onDismissed` callback (and the + // pending `display()` future) fire for the inline path too. + PurchaselyFlutterPlugin.loadedPresentations.remove(requestId) + PurchaselyFlutterPlugin.preparedRequests.remove(requestId) + PurchaselyFlutterPlugin.displayCallbacks.remove(requestId) + PurchaselyFlutterPlugin.emitPresentationEvent( + PurchaselyFlutterPlugin.eventEnvelope("onDismissed", requestId).apply { + put("outcome", PurchaselyFlutterPlugin.outcomeToMap(outcome)) + } + ) + } Log.d("Purchasely", "Presentation built successfully.") layout.addView(presentationView) } else { - Log.e("Purchasely", "PLYPresentation not found: using presentationId=$presentationId and placementId=$placementId.") - val presentationView = Purchasely.presentationView( - context = context, - properties = PLYPresentationProperties( - presentationId = presentationId, - placementId = placementId, - onClose = { closeCallback() } - ), - callback = { result, plan -> - methodChannel.invokeMethod( - "onPresentationResult", mapOf( - "result" to result.ordinal, - "plan" to plan?.toMap(), - ) - ) - } - ) - Log.d("Purchasely", "Presentation view created from fallback.") - - layout.addView(presentationView) - } - } - - private fun closeCallback() { - layout.removeAllViews() - } - - companion object { - fun parsePLYPresentationPlans(plans: List>?): List { - val parsedPlans = mutableListOf() - - plans?.forEach { planMap -> - val planVendorId = planMap["planVendorId"] as? String - val storeProductId = planMap["storeProductId"] as? String - val basePlanId = planMap["basePlanId"] as? String - val offerId = planMap["offerId"] as? String - - val presentationPlan = PLYPresentationPlan( - planVendorId = planVendorId, - storeProductId = storeProductId, - basePlanId = basePlanId, - storeOfferId = offerId, - offerVendorId = null, - default = false - ) - - parsedPlans.add(presentationPlan) - } - - return parsedPlans + Log.e("Purchasely", "Loaded Presentation not found for requestId=$requestId; nothing to display inline.") } } } diff --git a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeViewFactory.kt b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeViewFactory.kt index fe07b242..034cc10b 100644 --- a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeViewFactory.kt +++ b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeViewFactory.kt @@ -1,27 +1,23 @@ package io.purchasely.purchasely_flutter import android.content.Context -import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformViewFactory -import io.flutter.plugin.common.MethodChannel -class NativeViewFactory(binaryMessenger: BinaryMessenger) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { - private val channel: MethodChannel - - init { - channel = MethodChannel(binaryMessenger, CHANNEL_ID) - } +class NativeViewFactory : PlatformViewFactory(StandardMessageCodec.INSTANCE) { override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + @Suppress("UNCHECKED_CAST") val creationParams = args as Map? - return NativeView(context, viewId, creationParams, channel) + // The inline view surfaces its outcome through the plugin's shared + // `purchasely-presentation-events` sink (see NativeView), not a dedicated + // MethodChannel, so no per-view channel is needed. + return NativeView(context, viewId, creationParams) } companion object { const val VIEW_TYPE_ID = "io.purchasely.purchasely_flutter/native_view" - const val CHANNEL_ID = "native_view_channel" } } \ No newline at end of file diff --git a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PLYProductActivity.kt b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PLYProductActivity.kt deleted file mode 100644 index bc6d8596..00000000 --- a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PLYProductActivity.kt +++ /dev/null @@ -1,145 +0,0 @@ -package io.purchasely.purchasely_flutter - -import android.app.Activity -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import android.view.View -import android.widget.FrameLayout -import androidx.core.view.WindowCompat -import androidx.fragment.app.FragmentActivity -import io.purchasely.ext.PLYPresentation -import io.purchasely.ext.PLYPresentationProperties -import io.purchasely.ext.PLYProductViewResult -import io.purchasely.ext.Purchasely -import io.purchasely.models.PLYPlan -import io.purchasely.views.parseColor -import java.lang.ref.WeakReference - -class PLYProductActivity : FragmentActivity() { - - private var presentationId: String? = null - private var placementId: String? = null - private var productId: String? = null - private var planId: String? = null - private var contentId: String? = null - - private var presentation: PLYPresentation? = null - - private var isFullScreen: Boolean = false - private var backgroundColor: String? = null - - private var paywallView: View? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - isFullScreen = intent.extras?.getBoolean("isFullScreen") ?: false - backgroundColor = intent.extras?.getString("background_color") - - if(isFullScreen) { - WindowCompat.setDecorFitsSystemWindows(window, false) - hideSystemUI() - } - - setContentView(R.layout.activity_ply_product_activity) - - try { - val loadingBackgroundColor = backgroundColor.parseColor(Color.WHITE) - findViewById(R.id.container).setBackgroundColor(loadingBackgroundColor) - } catch (e: Exception) { - //do nothing - } - - presentationId = intent.extras?.getString("presentationId") - placementId = intent.extras?.getString("placementId") - productId = intent.extras?.getString("productId") - planId = intent.extras?.getString("planId") - contentId = intent.extras?.getString("contentId") - - presentation = intent.extras?.getParcelable("presentation") - - paywallView = if(presentation != null) { - presentation?.buildView(this, PLYPresentationProperties( - onClose = { - supportFinishAfterTransition() - } - ), callback) - } else { - Purchasely.presentationView( - context = this@PLYProductActivity, - properties = PLYPresentationProperties( - placementId = placementId, - contentId = contentId, - presentationId = presentationId, - planId = planId, - productId = productId, - onClose = { - findViewById(R.id.container).removeAllViews() - }, - onLoaded = { isLoaded -> - if(!isLoaded) return@PLYPresentationProperties - - val backgroundPaywall = paywallView?.findViewById(io.purchasely.R.id.content)?.background - if(backgroundPaywall != null) { - findViewById(R.id.container).background = backgroundPaywall - } - } - ), - callback = callback - ) - } - - if(paywallView == null) { - finish() - return - } - - - findViewById(R.id.container).addView(paywallView) - } - - private fun hideSystemUI() { - actionBar?.hide() - window.decorView.systemUiVisibility = ( - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN - ) - - } - - override fun onStart() { - super.onStart() - - PurchaselyFlutterPlugin.productActivity = PurchaselyFlutterPlugin.ProductActivity( - presentation = presentation, - presentationId = presentationId, - placementId = placementId, - productId = productId, - planId = planId, - contentId = contentId, - isFullScreen = isFullScreen, - loadingBackgroundColor = backgroundColor - ).apply { - activity = WeakReference(this@PLYProductActivity) - } - } - - override fun onDestroy() { - if(PurchaselyFlutterPlugin.productActivity?.activity?.get() == this) { - PurchaselyFlutterPlugin.productActivity?.activity = null - } - super.onDestroy() - } - - private val callback: (PLYProductViewResult, PLYPlan?) -> Unit = { result, plan -> - PurchaselyFlutterPlugin.sendPresentationResult(result, plan) - supportFinishAfterTransition() - } - - companion object { - fun newIntent(activity: Activity) = Intent(activity, PLYProductActivity::class.java) - } - -} \ No newline at end of file diff --git a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PLYSubscriptionsActivity.kt b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PLYSubscriptionsActivity.kt deleted file mode 100644 index ea91d0cc..00000000 --- a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PLYSubscriptionsActivity.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.purchasely.purchasely_flutter - -import android.os.Bundle -import androidx.fragment.app.FragmentActivity -import io.purchasely.ext.Purchasely - -class PLYSubscriptionsActivity : FragmentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_ply_subscriptions_activity) - - val fragment = Purchasely.subscriptionsFragment() ?: let { - supportFinishAfterTransition() - return - } - - supportFragmentManager - .beginTransaction() - .addToBackStack(null) - .replace(R.id.container, fragment, "SubscriptionsFragment") - .commitAllowingStateLoss() - - supportFragmentManager.addOnBackStackChangedListener { - if(supportFragmentManager.backStackEntryCount == 0) { - supportFinishAfterTransition() - } - } - } - -} diff --git a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt index e85d2527..f07594a9 100644 --- a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt +++ b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt @@ -11,18 +11,26 @@ import io.flutter.plugin.common.MethodChannel.Result import android.app.Activity import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log import androidx.annotation.NonNull -import androidx.fragment.app.FragmentActivity import io.purchasely.billing.Store import io.purchasely.ext.* import io.purchasely.ext.EventListener +import io.purchasely.ext.PLYActionInterceptorCallback +import io.purchasely.ext.PLYInterceptResult +import io.purchasely.ext.PLYInterceptorInfo +import io.purchasely.ext.presentation.PLYPresentationAction +import io.purchasely.ext.presentation.PLYPresentationBase +import io.purchasely.ext.presentation.PLYPresentationMetadata +import io.purchasely.ext.presentation.PLYPresentationOutcome +import io.purchasely.ext.presentation.PLYPresentationType +import io.purchasely.ext.presentation.display +import io.purchasely.ext.presentation.preload import io.purchasely.models.PLYPlan import io.purchasely.models.PLYPresentationPlan import io.purchasely.models.PLYProduct @@ -30,12 +38,16 @@ import kotlinx.coroutines.* import io.purchasely.ext.Purchasely import io.purchasely.models.PLYError import io.purchasely.views.presentation.PLYThemeMode +import io.purchasely.views.presentation.models.PLYDimensionType +import io.purchasely.views.presentation.models.PLYTransition +import io.purchasely.views.presentation.models.PLYTransitionDimension import io.purchasely.views.presentation.models.PLYTransitionType -import java.lang.ref.WeakReference import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.ConcurrentHashMap import kotlin.collections.ArrayList import kotlin.collections.HashMap +import kotlin.reflect.KClass import io.purchasely.ext.UserAttributeListener import io.purchasely.storage.userData.PLYUserAttributeSource import io.purchasely.storage.userData.PLYUserAttributeType @@ -50,10 +62,16 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, private lateinit var eventChannel: EventChannel private lateinit var purchaseChannel: EventChannel private lateinit var userAttributeChannel: EventChannel + private lateinit var presentationChannel: EventChannel private lateinit var context: Context private var activity: Activity? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var presentationSink: EventChannel.EventSink? + get() = activePresentationSink + set(value) { activePresentationSink = value } + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) job.cancel() @@ -151,86 +169,46 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, flutterPluginBinding .platformViewRegistry - .registerViewFactory(NativeViewFactory.VIEW_TYPE_ID, NativeViewFactory(flutterPluginBinding.binaryMessenger)) + .registerViewFactory(NativeViewFactory.VIEW_TYPE_ID, NativeViewFactory()) + + // Presentation/interceptor lifecycle events flow over a dedicated stream, + // discriminated by the `event` key; each carries a `requestId` so Dart can route back. + presentationChannel = EventChannel(flutterPluginBinding.binaryMessenger, PRESENTATION_EVENTS_CHANNEL) + presentationChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + presentationSink = events + } + + override fun onCancel(arguments: Any?) { + presentationSink = null + } + }) } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + @Suppress("UNCHECKED_CAST") + val args = (call.arguments as? Map) + when(call.method) { - "start" -> { - call.argument("apiKey")?.let { apiKey -> - start( - apiKey = apiKey, - stores = call.argument>("stores") ?: emptyList(), - storeKit1 = call.argument("storeKit1") ?: false, - userId = call.argument("userId"), - logLevel = call.argument("logLevel") ?: 1, - runningMode = call.argument("runningMode") ?: 3, - result = result - ) - } - } - "close" -> { - close() - result.safeSuccess(true) - } - "setDefaultPresentationResultHandler" -> setDefaultPresentationResultHandler(result) - "synchronize" -> { - synchronize() - result.safeSuccess(true) - } - "fetchPresentation" -> fetchPresentation( - call.argument("placementVendorId"), - call.argument("presentationVendorId"), - call.argument("contentId"), - result) - "presentPresentation" -> presentPresentation( - call.argument>("presentation"), - call.argument("isFullscreen") ?: false, - result) - "presentPresentationWithIdentifier" -> { - presentPresentationWithIdentifier( - call.argument("presentationVendorId"), - call.argument("contentId"), - call.argument("isFullscreen") - ) - presentationResult = result - } - "presentPresentationForPlacement" -> { - presentPresentationForPlacement( - call.argument("placementVendorId"), - call.argument("contentId"), - call.argument("isFullscreen") - ) - presentationResult = result - } - "presentProductWithIdentifier" -> { - val productId = call.argument("productVendorId") ?: let { - result.safeError("-1", "product vendor id must not be null", null) - return - } - presentProductWithIdentifier( - productId, - call.argument("presentationVendorId"), - call.argument("contentId"), - call.argument("isFullscreen") - ) - presentationResult = result - } - "presentPlanWithIdentifier" -> { - val planId = call.argument("planVendorId") ?: let { - result.safeError("-1", "plan vendor id must not be null", null) - return - } - presentPlanWithIdentifier( - planId, - call.argument("presentationVendorId"), - call.argument("contentId"), - call.argument("isFullscreen") - ) - presentationResult = result - } + // --- start --- + "start" -> start(args, result) + + // --- presentation lifecycle --- + "preload" -> preload(args, result) + "display" -> display(args, result) + "close" -> closePresentation(args, result) + "back" -> back(args, result) + + // --- action interceptor --- + "registerInterceptor" -> registerInterceptor(args, result) + "removeInterceptor" -> removeInterceptor(args, result) + "removeAllInterceptors" -> removeAllInterceptors(result) + "interceptorResolve" -> interceptorResolve(args, result) + + // --- kept v5 surface --- + "synchronize" -> synchronize(result) "restoreAllProducts" -> restoreAllProducts(result) - "silentRestoreAllProducts" -> restoreAllProducts(result) + "silentRestoreAllProducts" -> silentRestoreAllProducts(result) "getAnonymousUserId" -> result.safeSuccess(getAnonymousUserId()) "isAnonymous" -> result.safeSuccess(isAnonymous()) "isEligibleForIntroOffer" -> { @@ -259,10 +237,15 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, setLogLevel(call.argument("logLevel")) result.safeSuccess(true) } - "readyToOpenDeeplink" -> { - readyToOpenDeeplink(call.argument("readyToOpenDeeplink")) + "allowDeeplink" -> { + allowDeeplink(call.argument("allowDeeplink")) + result.safeSuccess(true) + } + "allowCampaigns" -> { + allowCampaigns(call.argument("allowCampaigns")) result.safeSuccess(true) } + "setDefaultPresentationDismissHandler" -> setDefaultPresentationDismissHandler(result) "setLanguage" -> { setLanguage(call.argument("language")) result.safeSuccess(true) @@ -271,14 +254,6 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, Purchasely.userDidConsumeSubscriptionContent() result.safeSuccess(true) } - "clientPresentationDisplayed" -> { - clientPresentationDisplayed(call.argument>("presentation")) - result.safeSuccess(true) - } - "clientPresentationClosed" -> { - clientPresentationClosed(call.argument>("presentation")) - result.safeSuccess(true) - } "productWithIdentifier" -> { launch { try { @@ -323,13 +298,9 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, displaySubscriptionCancellationInstruction() result.safeSuccess(true) } - "isDeeplinkHandled" -> isDeeplinkHandled(call.argument("deeplink"), result) + "handleDeeplink" -> handleDeeplink(call.argument("deeplink"), result) "userSubscriptions" -> launch { userSubscriptions(result) } "userSubscriptionsHistory" -> launch { userSubscriptionsHistory(result) } - "presentSubscriptions" -> { - presentSubscriptions() - result.safeSuccess(true) - } "setThemeMode" -> { setThemeMode(call.argument("mode")) result.safeSuccess(true) @@ -433,23 +404,6 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, clearBuiltInAttributes() result.safeSuccess(true) } - "setPaywallActionInterceptor" -> setPaywallActionInterceptor(result) - "onProcessAction" -> { - onProcessAction(call.argument("processAction") ?: false) - result.safeSuccess(true) - } - "closePresentation" -> { - closePresentation() - result.safeSuccess(true) - } - "hidePresentation" -> { - hidePresentation() - result.safeSuccess(true) - } - "showPresentation" -> { - showPresentation() - result.safeSuccess(true) - } "setDynamicOffering" -> { setDynamicOffering( call.argument("reference") ?: "", @@ -484,173 +438,420 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, } } - //region Purchasely - private fun start( - apiKey: String, - stores: List, - storeKit1: Boolean, - userId: String?, - logLevel: Int, - runningMode: Int, - result: Result - ) { + //region start + private fun logLevelFrom(raw: Any?): LogLevel { + return when (raw) { + is Number -> LogLevel.values().getOrElse(raw.toInt()) { LogLevel.ERROR } + is String -> LogLevel.values().firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: LogLevel.ERROR + else -> LogLevel.ERROR + } + } + + private fun runningModeFrom(raw: Any?): PLYRunningMode { + return when (raw) { + is Number -> when (raw.toInt()) { + 1 -> PLYRunningMode.Full + else -> PLYRunningMode.Observer + } + is String -> when (raw.lowercase(Locale.US)) { + "full" -> PLYRunningMode.Full + else -> PLYRunningMode.Observer + } + else -> PLYRunningMode.Observer + } + } + + private fun start(args: Map?, result: Result) { + val a = args ?: emptyMap() + val apiKey = a["apiKey"] as? String + if (apiKey.isNullOrBlank()) { + result.safeError("-1", "apiKey must not be null", null) + return + } + val userId = (a["appUserId"] as? String) ?: (a["userId"] as? String) + val logLevel = logLevelFrom(a["logLevel"]) + val runningMode = runningModeFrom(a["runningMode"]) + val stores = (a["stores"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList() + val allowDeeplink = a["allowDeeplink"] as? Boolean + val allowCampaigns = a["allowCampaigns"] as? Boolean ?: true + Purchasely.Builder(context) .apiKey(apiKey) .stores(getStoresInstances(stores)) - .logLevel(LogLevel.values()[logLevel]) - .runningMode(when(runningMode) { - 0 -> PLYRunningMode.Full - 1 -> PLYRunningMode.PaywallObserver - 2 -> PLYRunningMode.PaywallObserver - else -> PLYRunningMode.Full - }) + .logLevel(logLevel) + .runningMode(runningMode) .userId(userId) + .apply { + allowDeeplink?.let { this.allowDeeplink(it) } + this.allowCampaigns(allowCampaigns) + } .build() - Purchasely.sdkBridgeVersion = "5.7.3" + Purchasely.sdkBridgeVersion = "6.0.0-rc.1" Purchasely.appTechnology = PLYAppTechnology.FLUTTER - Purchasely.start { isConfigured, error -> - if(isConfigured) { + Purchasely.start { error -> + if (error == null) { result.safeSuccess(true) } else { - result.safeError("0", error?.message ?: "Purchasely SDK not configured", error) + result.safeError("0", error.message ?: "Purchasely SDK not configured", error) } } } + //endregion - private fun close() { - Purchasely.close() - } - - private fun fetchPresentation(placementId: String?, - presentationId: String?, - contentId: String?, - result: Result) { - - val properties = PLYPresentationProperties( - placementId = placementId, - presentationId = presentationId, - contentId = contentId) - - Purchasely.fetchPresentation( - properties = properties - ) { presentation: PLYPresentation?, error: PLYError? -> - launch { - if (presentation != null) { - presentationsLoaded.removeAll { it.id == presentation.id && it.placementId == presentation.placementId } - presentationsLoaded.add(presentation) - val map = presentation.toMap().mapValues { - val value = it.value - when(value) { - is PLYPresentationType -> value.ordinal - is PLYTransitionType -> value.ordinal - else -> value - } - } - val mutableMap = map.toMutableMap().apply { - this["height"] = presentation.height - this["metadata"] = presentation.metadata?.toMap() - this["plans"] = (this["plans"] as List).map { it.toMap() } - } - result.safeSuccess(mutableMap) - } - - if (error != null) result.safeError("467", error.message, error) + //region Presentation lifecycle + /** + * Build a `Prepared` presentation from a Dart-side request map. The map shape mirrors + * `PresentationRequest.toMap()` in `lib/src/presentation_request.dart`. + */ + private fun buildPrepared(request: Map): PLYPresentationBase.Prepared { + val requestId = request["requestId"] as? String + ?: error("presentation call missing requestId") + val source = request["source"] as? Map<*, *> + val sourceKind = source?.get("kind") as? String ?: "defaultSource" + val sourceId = source?.get("id") as? String + val contentId = request["contentId"] as? String + val backgroundColorHex = request["backgroundColor"] as? String + val progressColorHex = request["progressColor"] as? String + val displayCloseButton = request["displayCloseButton"] as? Boolean ?: true + val displayBackButton = request["displayBackButton"] as? Boolean ?: true + + val builder = PLYPresentationBase.builder().apply { + when (sourceKind) { + "placementId" -> sourceId?.let { placementId(it) } + "screenId" -> sourceId?.let { screenId(it) } + else -> { /* default source — no id */ } + } + contentId(contentId) + displayCloseButton(displayCloseButton) + displayBackButton(displayBackButton) + backgroundColorHex?.let { hex -> tryParseHexColor(hex)?.let { color -> backgroundColor(color) } } + progressColorHex?.let { hex -> tryParseHexColor(hex)?.let { color -> progressColor(color) } } + onPresented { presentation, error -> + emit(eventEnvelope("onPresented", requestId).apply { + put("presentation", presentation?.let { presentationToMap(it) }) + put("error", error?.let { errorToMap(it) }) + }) + } + onCloseRequested { + emit(eventEnvelope("onCloseRequested", requestId)) + } + onDismissed { outcome -> + displayCallbacks.remove(requestId) + emit(eventEnvelope("onDismissed", requestId).apply { + put("outcome", outcomeToMap(outcome)) + }) } } + + return builder.build().also { preparedRequests[requestId] = it } } - private fun presentPresentation(presentationMap: Map?, - isFullScreen: Boolean, - result: Result) { - if (presentationMap == null) { - result.safeError("-1", "presentation cannot be null", null) + private fun preload(args: Map?, result: Result) { + val a = args ?: emptyMap() + val requestId = a["requestId"] as? String + if (requestId.isNullOrBlank()) { + result.safeError("-1", "requestId is required", null) return } + val prepared = buildPrepared(a) + launch { + try { + val loaded = prepared.preload() + loadedPresentations[requestId] = loaded + emit(eventEnvelope("onLoaded", requestId).apply { + put("presentation", presentationToMap(loaded)) + }) + result.safeSuccess(presentationToMap(loaded)) + } catch (t: Throwable) { + val error = errorToMap(t) + emit(eventEnvelope("onLoaded", requestId).apply { + put("error", error) + }) + result.safeError("-1", t.message ?: "preload failed", t) + } + } + } - if(presentationsLoaded.none { it.id == presentationMap["id"] }) { - result.safeError("-1", "presentation was not fetched", null) + private fun display(args: Map?, result: Result) { + val a = args ?: emptyMap() + val requestId = a["requestId"] as? String + if (requestId.isNullOrBlank()) { + result.safeError("-1", "requestId is required", null) return } + val transition = parseTransition(a["transition"] as? Map<*, *>) + val ctx: Context = activity ?: context + + // The Dart-side Future returned from `display()` resolves at DISMISS via the + // `onDismissed` event. We MUST pass a real dismissal callback to display(): + // the native `dispatchDisplay`/DSL `display(...)` overloads do + // `onDismissed = callback` for any non-null callback, which would CLOBBER the + // builder-wired emitter with an empty lambda and silently drop the dismissal. + // Passing the emitter directly makes the dismissal reach Dart on both the + // success and error paths regardless of that clobbering. + val onDismissed: (PLYPresentationOutcome) -> Unit = { outcome -> + displayCallbacks.remove(requestId) + emit(eventEnvelope("onDismissed", requestId).apply { + put("outcome", outcomeToMap(outcome)) + }) + } + displayCallbacks[requestId] = onDismissed + + try { + // A loaded presentation displays directly; otherwise display from the prepared. + val loaded = loadedPresentations[requestId] + if (loaded != null) { + loaded.display(ctx, transition, onDismissed) + } else { + val prepared = preparedRequests[requestId] ?: buildPrepared(a) + prepared.display(ctx, transition, { /* onLoaded — not awaited by Dart here */ }, onDismissed) + } + result.safeSuccess(true) + } catch (t: Throwable) { + displayCallbacks.remove(requestId) + result.safeError("-1", t.message ?: "display failed", t) + } + } + + private fun closePresentation(args: Map?, result: Result) { + // The native SDK does not expose per-presentation programmatic close; + // close all screens regardless of whether a requestId was provided. + Purchasely.closeAllScreens() + result.safeSuccess(true) + } + + private fun back(args: Map?, result: Result) { + val requestId = args?.get("requestId") as? String + val loaded = requestId?.let { loadedPresentations[it] } + loaded?.back() + result.safeSuccess(true) + } + //endregion - val presentation = presentationsLoaded.lastOrNull { - it.id == presentationMap["id"] - && it.placementId == presentationMap["placementId"] + //region Default presentation dismiss handler + private fun setDefaultPresentationDismissHandler(result: Result) { + Purchasely.setDefaultPresentationDismissHandler { outcome: PLYPresentationOutcome -> + emit(eventEnvelope("onDefaultPresentationDismissed", "").apply { + put("outcome", outcomeToMap(outcome)) + }) } + result.safeSuccess(true) + } + //endregion - if(presentation == null) { - result.safeError("468", "Presentation not found", NullPointerException("presentation not fond")) + //region Action interceptor + private fun registerInterceptor(args: Map?, result: Result) { + val kindWire = args?.get("kind") as? String + val kindKClass = actionKClassForWire(kindWire) + if (kindKClass == null) { + result.safeError("-1", "unknown action kind '$kindWire'", null) return } + Purchasely.interceptAction(kindKClass.java, object : PLYActionInterceptorCallback { + override fun onIntercept( + info: PLYInterceptorInfo, + action: PLYPresentationAction, + completion: (PLYInterceptResult) -> Unit + ) { + val id = "ply_ic_${System.nanoTime()}" + pendingInterceptors[id] = completion + emit( + eventEnvelope("interceptorTriggered", id).apply { + put("kind", kindWire) + put("info", interceptorInfoToMap(info)) + put("payload", actionPayloadToMap(action)) + } + ) + } + }) + result.safeSuccess(true) + } - presentationResult = result + private fun removeInterceptor(args: Map?, result: Result) { + val kindWire = args?.get("kind") as? String + val kindKClass = actionKClassForWire(kindWire) + if (kindKClass != null) { + Purchasely.removeActionInterceptor(kindKClass.java) + } + result.safeSuccess(true) + } - activity?.let { - if (presentation.flowId != null) { - presentation.display(it) { result, plan -> - sendPresentationResult(result, plan) - } - } else { - // Open legacy Activity for now if not a flow - val intent = PLYProductActivity.newIntent(it).apply { - putExtra("presentation", presentation) - putExtra("isFullScreen", isFullScreen) - } - it.startActivity(intent) - } + private fun removeAllInterceptors(result: Result) { + Purchasely.removeAllActionInterceptors() + result.safeSuccess(true) + } + + private fun interceptorResolve(args: Map?, result: Result) { + val id = args?.get("invocationId") as? String + val value = args?.get("result") as? String + val ply = when (value) { + "success" -> PLYInterceptResult.SUCCESS + "failed" -> PLYInterceptResult.FAILED + else -> PLYInterceptResult.NOT_HANDLED + } + val completion = id?.let { pendingInterceptors.remove(it) } + activity?.runOnUiThread { completion?.invoke(ply) } ?: completion?.invoke(ply) + result.safeSuccess(true) + } + + private fun actionKClassForWire(value: String?): KClass? { + val backendValue = when (value) { + "close" -> "close" + "close_all" -> "close_all" + "login" -> "login" + "navigate" -> "navigate" + "purchase" -> "purchase" + "restore" -> "restore" + "open_presentation" -> "open_presentation" + "open_placement" -> "open_placement" + "promo_code" -> "promo_code" + "web_checkout" -> "web_checkout" + else -> return null + } + return PLYPresentationAction.fromValue(backendValue) + } + //endregion + + //region Event channel sink + private fun emit(event: Map) { + emitPresentationEvent(event) + } + + private fun eventEnvelope(event: String, requestId: String): MutableMap = + Companion.eventEnvelope(event, requestId) + //endregion + + //region Presentation serializers + private fun outcomeToMap(outcome: PLYPresentationOutcome): Map = + Companion.outcomeToMap(outcome) + + private fun errorToMap(error: Throwable): Map { + return mapOf( + "code" to error.javaClass.simpleName, + "message" to error.message, + ) + } + + private fun interceptorInfoToMap(info: PLYInterceptorInfo): Map { + return mapOf( + "contentId" to info.contentId, + "presentation" to info.presentation?.let { presentationToMap(it) }, + ) + } + + private fun actionPayloadToMap(action: PLYPresentationAction): Map? { + return when (action) { + is PLYPresentationAction.Navigate -> mapOf( + "url" to action.url.toString(), + "title" to action.title, + ) + is PLYPresentationAction.Purchase -> mapOf( + "plan" to mapOf( + "vendorId" to action.plan.vendorId, + "productId" to action.plan.getProductId(), + "basePlanId" to action.plan.basePlanId, + ), + "subscriptionOffer" to action.subscriptionOffer?.toMap(), + "offer" to action.offer?.let { offer -> + mapOf( + "vendorId" to offer.vendorId, + "storeOfferId" to offer.storeOfferId, + "publicId" to offer.publicId, + ) + }, + ) + is PLYPresentationAction.Close -> mapOf("closeReason" to action.closeReason.value) + is PLYPresentationAction.CloseAll -> mapOf("closeReason" to action.closeReason.value) + is PLYPresentationAction.OpenPresentation -> mapOf( + "presentationId" to action.presentationId, + ) + is PLYPresentationAction.OpenPlacement -> mapOf( + "placementId" to action.placementId, + ) + is PLYPresentationAction.WebCheckout -> mapOf( + "url" to action.url.toString(), + "clientReferenceId" to action.clientReferenceId, + "queryParameterKey" to action.queryParameterKey, + "webCheckoutProvider" to action.webCheckoutProvider.name, + ) + else -> null } } - private fun presentPresentationWithIdentifier(presentationVendorId: String?, - contentId: String?, - isFullscreen: Boolean?) { - val intent = Intent(context, PLYProductActivity::class.java) - intent.putExtra("presentationId", presentationVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullscreen ?: false) - activity?.startActivity(intent) - } - - private fun presentPresentationForPlacement(placementVendorId: String?, - contentId: String?, - isFullscreen: Boolean?) { - val intent = Intent(context, PLYProductActivity::class.java) - intent.putExtra("placementId", placementVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullscreen ?: false) - activity?.startActivity(intent) - } - - private fun presentProductWithIdentifier(productVendorId: String, - presentationVendorId: String?, - contentId: String?, - isFullscreen: Boolean?) { - val intent = Intent(context, PLYProductActivity::class.java) - intent.putExtra("presentationId", presentationVendorId) - intent.putExtra("productId", productVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullscreen ?: false) - activity?.startActivity(intent) - } - - private fun presentPlanWithIdentifier(planVendorId: String, - presentationVendorId: String?, - contentId: String?, - isFullscreen: Boolean?) { - val intent = Intent(context, PLYProductActivity::class.java) - intent.putExtra("presentationId", presentationVendorId) - intent.putExtra("planId", planVendorId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullscreen ?: false) - activity?.startActivity(intent) + /** + * Parses a Dart transition dimension `{ "type": "pixel"|"percentage", "value": }` + * into a native [PLYTransitionDimension]. Returns `null` (→ surface default / hug) when + * absent or malformed. + */ + private fun parseDimension(raw: Any?): PLYTransitionDimension? { + val map = raw as? Map<*, *> ?: return null + val value = (map["value"] as? Number)?.toFloat() ?: return null + val type = if (map["type"] as? String == "pixel") { + PLYDimensionType.PIXEL + } else { + PLYDimensionType.PERCENTAGE + } + return PLYTransitionDimension(type, value) + } + + private fun parseTransition(map: Map<*, *>?): PLYTransition? { + if (map == null) return null + val type = when (map["type"] as? String) { + "fullScreen" -> PLYTransitionType.FULLSCREEN + "push" -> PLYTransitionType.PUSH + "modal" -> PLYTransitionType.MODAL + "drawer" -> PLYTransitionType.DRAWER + "popin" -> PLYTransitionType.POPIN + "inlinePaywall" -> PLYTransitionType.INLINE_PAYWALL + else -> return null + } + val dismissible = map["dismissible"] as? Boolean ?: true + // v6 models drawer/popin size as PLYTransitionDimension (width is popin-only, + // height drives drawer + popin). The legacy `heightPercentage` constructor arg + // is deprecated and intentionally not set. + val width = parseDimension(map["width"]) + val height = parseDimension(map["height"]) + return PLYTransition( + type = type, + width = width, + height = height, + dismissible = dismissible, + ) } + private fun tryParseHexColor(hex: String): Int? { + return try { + val cleaned = hex.trim().removePrefix("#") + val full = if (cleaned.length == 6) "FF$cleaned" else cleaned + full.toLong(16).toInt() + } catch (_: Throwable) { + null + } + } + //endregion + + //region Purchasely private fun restoreAllProducts(result: Result) { Purchasely.restoreAllProducts( - onSuccess = { plan -> + onSuccess = { + result.safeSuccess(true) + }, + onError = { error -> + error?.let { + result.safeError("-1", it.message, it) + } ?: let { + result.safeError("-1", "Unknown error", null) + } + } + ) + } + + private fun silentRestoreAllProducts(result: Result) { + Purchasely.silentRestoreAllProducts( + onSuccess = { result.safeSuccess(true) - Purchasely.restoreAllProducts(null) }, onError = { error -> error?.let { @@ -658,7 +859,6 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, } ?: let { result.safeError("-1", "Unknown error", null) } - Purchasely.restoreAllProducts(null) } ) } @@ -699,26 +899,32 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, } private fun userLogout() { - Purchasely.userLogout() + Purchasely.userLogout(true) } private fun setLogLevel(logLevel: Int?) { Purchasely.logLevel = LogLevel.values()[logLevel ?: 0] } - private fun readyToOpenDeeplink(readyToOpenDeeplink: Boolean?) { - Purchasely.readyToOpenDeeplink = readyToOpenDeeplink ?: true + private fun allowDeeplink(allowDeeplink: Boolean?) { + Purchasely.allowDeeplink = allowDeeplink ?: true } - private fun setDefaultPresentationResultHandler(result: Result) { - defaultPresentationResult = result - Purchasely.setDefaultPresentationResultHandler { result2, plan -> - sendPresentationResult(result2, plan) - } + private fun allowCampaigns(allowCampaigns: Boolean?) { + Purchasely.allowCampaigns = allowCampaigns ?: true } - private fun synchronize() { - Purchasely.synchronize() + private fun synchronize(result: Result) { + // v6 exposes onSuccess/onError callbacks on synchronize(). The Dart + // `Purchasely.synchronize()` Future now resolves once the receipt + // synchronisation completes (and errors via PlatformException) instead + // of the old fire-and-forget behaviour. + Purchasely.synchronize( + onSuccess = { result.safeSuccess(true) }, + onError = { error -> + result.safeError("-1", error?.message ?: "Synchronization failed", error) + } + ) } private suspend fun productWithIdentifier(vendorId: String?) : PLYProduct? { @@ -748,51 +954,24 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, } } - private fun isDeeplinkHandled(deeplink: String?, result: Result) { + private fun handleDeeplink(deeplink: String?, result: Result) { if (deeplink == null) { result.safeError("-1", "Deeplink must not be null", null) return } val uri = Uri.parse(deeplink) - result.safeSuccess(Purchasely.isDeeplinkHandled(uri)) + result.safeSuccess(Purchasely.handleDeeplink(uri, activity)) } private fun displaySubscriptionCancellationInstruction() { - val flutterActivity = activity - if(flutterActivity is FragmentActivity) { - Purchasely.displaySubscriptionCancellationInstruction(flutterActivity, 0) - } + // The native Android v6 SDK removed the built-in cancellation survey UI. + Log.w("Purchasely", "displaySubscriptionCancellationInstruction is no longer supported by the Android v6 SDK") } private suspend fun userSubscriptions(result: Result) { try { - val subscriptions = Purchasely.userSubscriptions() - val list = ArrayList>() - for (data in subscriptions) { - val map = data.data.toMap().toMutableMap().apply { - this["subscriptionSource"] = when(data.data.storeType) { - StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal - StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal - StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal - StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal - else -> null - } - - this["plan"] = transformPlanToMap(data.plan) - - val plans = HashMap() - data.product.plans.map { - plans.put(it.name, transformPlanToMap(it)) - } - this["product"] = data.product.toMap().toMutableMap().apply { - this["plans"] = plans - } - remove("subscription_status") //TODO add in a future version after checking with iOS - } - list.add(map) - //list[data.data.id] = map - } - result.safeSuccess(list) + val subscriptions = Purchasely.userSubscriptions(true) + result.safeSuccess(transformSubscriptionsToList(subscriptions)) } catch (e: Exception) { result.safeError("-1", e.message, e) } @@ -800,41 +979,39 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, private suspend fun userSubscriptionsHistory(result: Result) { try { - val subscriptions = Purchasely.userSubscriptionsHistory() - val list = ArrayList>() - for (data in subscriptions) { - val map = data.data.toMap().toMutableMap().apply { - this["subscriptionSource"] = when(data.data.storeType) { - StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal - StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal - StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal - StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal - else -> null - } - - this["plan"] = transformPlanToMap(data.plan) - - val plans = HashMap() - data.product.plans.map { - plans.put(it.name, transformPlanToMap(it)) - } - this["product"] = data.product.toMap().toMutableMap().apply { - this["plans"] = plans - } - remove("subscription_status") //TODO add in a future version after checking with iOS - } - list.add(map) - //list[data.data.id] = map - } - result.safeSuccess(list) + val subscriptions = Purchasely.userSubscriptionsHistory(true) + result.safeSuccess(transformSubscriptionsToList(subscriptions)) } catch (e: Exception) { result.safeError("-1", e.message, e) } } - private fun presentSubscriptions() { - val intent = Intent(context, PLYSubscriptionsActivity::class.java) - activity?.startActivity(intent) + private fun transformSubscriptionsToList(subscriptions: List): ArrayList> { + val list = ArrayList>() + for (data in subscriptions) { + val map = data.data.toMap().toMutableMap().apply { + this["subscriptionSource"] = when(data.data.storeType) { + StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal + StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal + StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal + StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal + else -> null + } + + this["plan"] = transformPlanToMap(data.plan) + + val plans = HashMap() + data.product.plans.map { + plans.put(it.name, transformPlanToMap(it)) + } + this["product"] = data.product.toMap().toMutableMap().apply { + this["plans"] = plans + } + remove("subscription_status") //TODO add in a future version after checking with iOS + } + list.add(map) + } + return list } private fun setThemeMode(mode: Int?) { @@ -1007,117 +1184,11 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, } } - private fun clientPresentationDisplayed(presentationMap: Map?) { - if(presentationMap == null) { - PLYLogger.e("presentation cannot be null") - return - } - - val presentation = presentationsLoaded.firstOrNull { it.id == presentationMap["id"]} - - if(presentation != null) { - Purchasely.clientPresentationDisplayed(presentation) - } - } - - private fun clientPresentationClosed(presentationMap: Map?) { - if(presentationMap == null) { - PLYLogger.e("presentation cannot be null") - return - } - - val presentation = presentationsLoaded.firstOrNull { it.id == presentationMap["id"]} - - if(presentation != null) { - Purchasely.clientPresentationClosed(presentation) - presentationsLoaded.removeAll { it.id == presentation.id } - } - } - - - private fun setPaywallActionInterceptor(result: Result) { - Purchasely.setPaywallActionsInterceptor { info, action, parameters, processAction -> - paywallActionHandler = processAction - paywallAction = action - - val parametersForFlutter = hashMapOf(); - - parametersForFlutter["title"] = parameters.title - parametersForFlutter["url"] = parameters.url?.toString() - parametersForFlutter["presentation"] = parameters.presentation - parametersForFlutter["placement"] = parameters.placement - parametersForFlutter["plan"] = transformPlanToMap(parameters.plan) - parametersForFlutter["offer"] = mapOf( - "vendorId" to parameters.offer?.vendorId, - "storeOfferId" to parameters.offer?.storeOfferId - ) - parametersForFlutter["subscriptionOffer"] = parameters.subscriptionOffer?.toMap() - parametersForFlutter["closeReason"] = parameters?.closeReason?.name - parametersForFlutter["clientReferenceId"] = parameters?.clientReferenceId - parametersForFlutter["queryParameterKey"] = parameters?.queryParameterKey - parametersForFlutter["webCheckoutProvider"] = parameters?.webCheckoutProvider?.name - - result.safeSuccess(mapOf( - Pair("info", mapOf( - Pair("contentId", info?.contentId), - Pair("presentationId", info?.presentationId), - Pair("placementId", info?.placementId), - Pair("abTestId", info?.abTestId), - Pair("abTestVariantId", info?.abTestVariantId) - )), - Pair("action", when(action) { - PLYPresentationAction.PURCHASE -> "purchase" - PLYPresentationAction.CLOSE -> "close" - PLYPresentationAction.CLOSE_ALL -> "close_all" - PLYPresentationAction.LOGIN -> "login" - PLYPresentationAction.NAVIGATE -> "navigate" - PLYPresentationAction.RESTORE -> "restore" - PLYPresentationAction.OPEN_PRESENTATION -> "open_presentation" - PLYPresentationAction.PROMO_CODE -> "promo_code" - PLYPresentationAction.OPEN_PLACEMENT -> "open_placement" - PLYPresentationAction.OPEN_FLOW_STEP -> "open_flow_step" - PLYPresentationAction.WEB_CHECKOUT -> "web_checkout" - }), - Pair("parameters", parametersForFlutter) - )) - } - } - - private fun showPresentation() { - launch { - productActivity?.relaunch(activity) - withContext(Dispatchers.Default) { delay(500) } - } - } - - private fun onProcessAction(processAction: Boolean) { - activity?.let { - it.runOnUiThread { - paywallActionHandler?.invoke(processAction) - } - } - } - - private fun closePresentation() { - Purchasely.closeAllScreens() - productActivity = null - } - - private fun hidePresentation() { - val flutterActivity = activity - val currentActivity = productActivity?.activity?.get() ?: flutterActivity - if(flutterActivity != null && currentActivity != null) { - flutterActivity.startActivity(Intent(currentActivity, flutterActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT - }) - } - } - private suspend fun isEligibleForIntroOffer(planVendorId: String) : Boolean { return try { val plan = Purchasely.plan(planVendorId) if(plan != null) { - plan.isEligibleToIntroOffer() + plan.isEligibleToOffer(null) } else { Log.e("Purchasely", "plan $planVendorId not found") false @@ -1175,20 +1246,19 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, private fun getStoresInstances(stores: List?): ArrayList { val result = ArrayList() - if (stores?.contains("Google") == true - && Package.getPackage("io.purchasely.google") != null) { - try { - result.add(Class.forName("io.purchasely.google.GoogleStore").newInstance() as Store) - } catch (e: Exception) { - Log.e("Purchasely", "Google Store not found :" + e.message, e) - } - } - if (stores?.contains("Huawei") == true - && Package.getPackage("io.purchasely.huawei") != null) { - try { - result.add(Class.forName("io.purchasely.huawei.HuaweiStore").newInstance() as Store) - } catch (e: Exception) { - Log.e("Purchasely", e.message, e) + stores.orEmpty().forEach { store -> + val className = when (store.lowercase(Locale.US)) { + "google" -> "io.purchasely.google.GoogleStore" + "huawei" -> "io.purchasely.huawei.HuaweiStore" + "amazon" -> "io.purchasely.amazon.AmazonStore" + else -> null + } + if (className != null) { + try { + result.add(Class.forName(className).getDeclaredConstructor().newInstance() as Store) + } catch (e: Exception) { + Log.e("Purchasely", "$store Store not found: ${e.message}", e) + } } } return result @@ -1213,70 +1283,6 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, private val job = SupervisorJob() override val coroutineContext = job + Dispatchers.Main - class ProductActivity( - val presentation: PLYPresentation? = null, - val presentationId: String? = null, - val placementId: String? = null, - val productId: String? = null, - val planId: String? = null, - val contentId: String? = null, - val isFullScreen: Boolean = false, - val loadingBackgroundColor: String? = null,) { - - var activity: WeakReference? = null - - fun relaunch(flutterActivity: Activity?) : Boolean { - if(flutterActivity == null) return false - - val backgroundActivity = activity?.get() - return if(backgroundActivity != null - && !backgroundActivity.isFinishing) { - backgroundActivity.startActivity( - Intent(backgroundActivity, backgroundActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT - } - ) - true - } else { - val intent = PLYProductActivity.newIntent(flutterActivity) - intent.putExtra("presentation", presentation) - intent.putExtra("presentationId", presentationId) - intent.putExtra("placementId", placementId) - intent.putExtra("productId", productId) - intent.putExtra("planId", planId) - intent.putExtra("contentId", contentId) - intent.putExtra("isFullScreen", isFullScreen) - intent.putExtra("background_color", loadingBackgroundColor) - flutterActivity.startActivity(intent) - return false - } - } - } - - fun PLYPresentationPlan.toMap() : Map { - return mapOf( - Pair("planVendorId", planVendorId), - Pair("storeProductId", storeProductId), - Pair("basePlanId", basePlanId), - //Pair("offerId", offerId) - ) - } - - suspend fun PLYPresentationMetadata.toMap() : Map { - val metadata = mutableMapOf() - this.keys()?.forEach { key -> - val value = when (this.type(key)) { - kotlin.String::class.java.simpleName -> this.getString(key) - else -> this.get(key) - } - value?.let { - metadata.put(key, it) - } - } - - return metadata - } - private fun Result.safeSuccess(map: Map) { try { this.success(map) @@ -1310,34 +1316,99 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, } companion object { - var productActivity: ProductActivity? = null - var presentationResult: Result? = null - var defaultPresentationResult: Result? = null - var paywallActionHandler: PLYCompletionHandler? = null - var paywallAction: PLYPresentationAction? = null - private lateinit var channel : MethodChannel + private const val PRESENTATION_EVENTS_CHANNEL = "purchasely-presentation-events" - val presentationsLoaded = mutableListOf() + private lateinit var channel : MethodChannel - fun sendPresentationResult(result: PLYProductViewResult, plan: PLYPlan?) { - val productViewResult = when(result) { - PLYProductViewResult.PURCHASED -> PLYProductViewResult.PURCHASED.ordinal - PLYProductViewResult.CANCELLED -> PLYProductViewResult.CANCELLED.ordinal - PLYProductViewResult.RESTORED -> PLYProductViewResult.RESTORED.ordinal + // The live presentation-events sink shared by the full-screen path and the + // inline NativeView, plus a main-thread handler to post onto it. The inline + // view emits its onDismissed envelope through `emitPresentationEvent` so it + // is byte-for-byte identical to the full-screen path. + @Volatile + private var activePresentationSink: EventChannel.EventSink? = null + private val presentationHandler = Handler(Looper.getMainLooper()) + + // Prepared/loaded presentations keyed by Dart requestId. They are retained after + // dismissal so a Dart Presentation handle can be displayed again and so the inline + // platform view can resolve a preloaded requestId. There is no native dispose API yet. + val preparedRequests = ConcurrentHashMap() + val loadedPresentations = ConcurrentHashMap() + val displayCallbacks = ConcurrentHashMap Unit>() + + /** + * Posts a presentation lifecycle envelope onto the shared + * `purchasely-presentation-events` sink. Used by the inline NativeView so + * the embedded path surfaces the same `{ event, requestId, outcome }` + * envelopes as the full-screen path. + */ + fun emitPresentationEvent(event: Map) { + presentationHandler.post { + activePresentationSink?.success(event) } + } - if(presentationResult != null) { - presentationResult?.success( - mapOf(Pair("result", productViewResult), Pair("plan", transformPlanToMap(plan))) - ) - presentationResult = null - } else if(defaultPresentationResult != null) { - defaultPresentationResult?.success( - mapOf(Pair("result", productViewResult), Pair("plan", transformPlanToMap(plan))) - ) - } + /** Builds the base `{ event, requestId }` envelope shared by all callers. */ + fun eventEnvelope(event: String, requestId: String): MutableMap { + return mutableMapOf( + "event" to event, + "requestId" to requestId, + ) + } + + /** + * Serializes a presentation outcome to the wire shape consumed by the Dart + * façade. Shared by the full-screen and inline paths so both are identical. + */ + fun outcomeToMap(outcome: PLYPresentationOutcome): Map { + return mapOf( + "presentation" to outcome.presentation?.let { presentationToMap(it) }, + "purchaseResult" to outcome.purchaseResult?.name?.lowercase(), + // Serialize the full PLYPlan (same shape as products/plans elsewhere) + // so the Dart side parses it into a fully-typed PLYPlan via plyPlanFromMap. + "plan" to outcome.plan?.let { transformPlanToMap(it) }, + "closeReason" to outcome.closeReason?.value, + "error" to outcome.error?.let { errorToMap(it) }, + ) + } + + private fun presentationToMap(p: PLYPresentationBase.Loaded): Map { + return mapOf( + "screenId" to p.screenId, + "placementId" to p.placementId, + "contentId" to p.contentId, + "audienceId" to p.audienceId, + "abTestId" to p.abTestId, + "abTestVariantId" to p.abTestVariantId, + "campaignId" to p.campaignId, + "flowId" to p.flowId, + "language" to p.language, + "type" to p.type.ordinal, + "height" to p.height, + "plans" to p.plans.map { plan -> presentationPlanToMap(plan) }, + ) + } + + private fun presentationPlanToMap(plan: PLYPresentationPlan): Map { + return mapOf( + "planVendorId" to plan.planVendorId, + "storeProductId" to plan.storeProductId, + "basePlanId" to plan.basePlanId, + "offerId" to plan.storeOfferId, + ) } + private fun errorToMap(error: PLYError): Map { + return mapOf( + "code" to "PLYError", + "message" to error.message, + ) + } + + // Pending interceptor invocations awaiting Dart resolution, keyed by the + // invocation id (`ply_ic_`) sent to Dart so `interceptorResolve` + // can route to the right SDK completion. + val pendingInterceptors = ConcurrentHashMap Unit>() + private fun transformPlanToMap(plan: PLYPlan?): Map { if(plan == null) return emptyMap() diff --git a/purchasely/android/src/main/res/layout/activity_ply_product_activity.xml b/purchasely/android/src/main/res/layout/activity_ply_product_activity.xml deleted file mode 100644 index 048fecfd..00000000 --- a/purchasely/android/src/main/res/layout/activity_ply_product_activity.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/purchasely/android/src/main/res/layout/activity_ply_subscriptions_activity.xml b/purchasely/android/src/main/res/layout/activity_ply_subscriptions_activity.xml deleted file mode 100644 index 83436f73..00000000 --- a/purchasely/android/src/main/res/layout/activity_ply_subscriptions_activity.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/purchasely/android/src/test/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPluginTest.kt b/purchasely/android/src/test/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPluginTest.kt index bb0e874f..5015f8fc 100644 --- a/purchasely/android/src/test/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPluginTest.kt +++ b/purchasely/android/src/test/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPluginTest.kt @@ -7,19 +7,16 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import io.mockk.* +import io.mockk.MockKAnnotations +import io.mockk.every import io.mockk.impl.annotations.MockK -import io.purchasely.ext.* -import io.purchasely.models.PLYPlan -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class PurchaselyFlutterPluginTest { private lateinit var plugin: PurchaselyFlutterPlugin @@ -42,13 +39,9 @@ class PurchaselyFlutterPluginTest { @MockK(relaxed = true) private lateinit var mockActivityBinding: ActivityPluginBinding - private val testDispatcher = StandardTestDispatcher() - @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - Dispatchers.setMain(testDispatcher) - plugin = PurchaselyFlutterPlugin() every { mockFlutterPluginBinding.binaryMessenger } returns mockBinaryMessenger @@ -59,21 +52,15 @@ class PurchaselyFlutterPluginTest { @After fun tearDown() { - Dispatchers.resetMain() + PurchaselyFlutterPlugin.preparedRequests.clear() + PurchaselyFlutterPlugin.loadedPresentations.clear() + PurchaselyFlutterPlugin.displayCallbacks.clear() + PurchaselyFlutterPlugin.pendingInterceptors.clear() unmockkAll() - // Clear companion object state - PurchaselyFlutterPlugin.presentationResult = null - PurchaselyFlutterPlugin.defaultPresentationResult = null - PurchaselyFlutterPlugin.paywallActionHandler = null - PurchaselyFlutterPlugin.paywallAction = null - PurchaselyFlutterPlugin.productActivity = null - PurchaselyFlutterPlugin.presentationsLoaded.clear() } - // region Plugin Lifecycle Tests - @Test - fun `onAttachedToEngine sets up channels correctly`() { + fun `onAttachedToEngine sets up channels`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) verify { mockFlutterPluginBinding.binaryMessenger } @@ -81,644 +68,104 @@ class PurchaselyFlutterPluginTest { } @Test - fun `onDetachedFromEngine cleans up without exceptions`() { + fun `onDetachedFromEngine cleans up without throwing`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - // Should not throw any exception - assertDoesNotThrow { - plugin.onDetachedFromEngine(mockFlutterPluginBinding) - } + plugin.onDetachedFromEngine(mockFlutterPluginBinding) } @Test - fun `onAttachedToActivity sets activity reference`() { + fun `onAttachedToActivity stores activity binding`() { plugin.onAttachedToActivity(mockActivityBinding) verify { mockActivityBinding.activity } } @Test - fun `onDetachedFromActivity does not throw`() { - plugin.onAttachedToActivity(mockActivityBinding) - - assertDoesNotThrow { - plugin.onDetachedFromActivity() - } - } - - @Test - fun `onDetachedFromActivityForConfigChanges does not throw`() { - plugin.onAttachedToActivity(mockActivityBinding) - - assertDoesNotThrow { - plugin.onDetachedFromActivityForConfigChanges() - } - } - - @Test - fun `onReattachedToActivityForConfigChanges restores activity`() { - plugin.onReattachedToActivityForConfigChanges(mockActivityBinding) - - verify { mockActivityBinding.activity } - } - - // endregion - - // region Method Call Routing Tests - - @Test - fun `onMethodCall with unknown method returns not implemented`() { + fun `unknown method is not implemented`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - val call = MethodCall("unknownMethod", null) - plugin.onMethodCall(call, mockResult) + plugin.onMethodCall(MethodCall("unknownMethod", null), mockResult) verify { mockResult.notImplemented() } } @Test - fun `userLogin with null userId returns error`() { + fun `start without apiKey returns argument error`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - val call = MethodCall("userLogin", mapOf()) - plugin.onMethodCall(call, mockResult) + plugin.onMethodCall(MethodCall("start", mapOf()), mockResult) - verify { mockResult.error("-1", "user id must not be null", null) } + verify { mockResult.error("-1", "apiKey must not be null", null) } } @Test - fun `presentProductWithIdentifier with null productId returns error`() { + fun `preload without requestId returns argument error`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - val call = MethodCall("presentProductWithIdentifier", mapOf()) - plugin.onMethodCall(call, mockResult) + plugin.onMethodCall(MethodCall("preload", mapOf()), mockResult) - verify { mockResult.error("-1", "product vendor id must not be null", null) } + verify { mockResult.error("-1", "requestId is required", null) } } @Test - fun `presentPlanWithIdentifier with null planId returns error`() { + fun `display without requestId returns argument error`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - val call = MethodCall("presentPlanWithIdentifier", mapOf()) - plugin.onMethodCall(call, mockResult) + plugin.onMethodCall(MethodCall("display", mapOf()), mockResult) - verify { mockResult.error("-1", "plan vendor id must not be null", null) } + verify { mockResult.error("-1", "requestId is required", null) } } @Test - fun `presentPresentation with null presentation returns error`() { + fun `registerInterceptor rejects unknown action kind`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - val call = MethodCall("presentPresentation", mapOf()) - plugin.onMethodCall(call, mockResult) - - verify { mockResult.error("-1", "presentation cannot be null", null) } - } - - @Test - fun `presentPresentation with unfetched presentation returns error`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val presentationMap = mapOf( - "id" to "some-id", - "placementId" to "some-placement" + plugin.onMethodCall( + MethodCall("registerInterceptor", mapOf("kind" to "unknown_action")), + mockResult, ) - val call = MethodCall("presentPresentation", mapOf("presentation" to presentationMap)) - plugin.onMethodCall(call, mockResult) - - verify { mockResult.error("-1", "presentation was not fetched", null) } + verify { mockResult.error("-1", "unknown action kind 'unknown_action'", null) } } @Test - fun `isDeeplinkHandled with null deeplink returns error`() { + fun `handleDeeplink without deeplink returns argument error`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - val call = MethodCall("isDeeplinkHandled", mapOf()) - plugin.onMethodCall(call, mockResult) + plugin.onMethodCall(MethodCall("handleDeeplink", mapOf()), mockResult) verify { mockResult.error("-1", "Deeplink must not be null", null) } } @Test - fun `isEligibleForIntroOffer with null planVendorId returns error`() = runTest { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("isEligibleForIntroOffer", mapOf()) - plugin.onMethodCall(call, mockResult) - - advanceUntilIdle() - - verify { mockResult.error("-1", "planVendorId must not be null", null) } - } - - @Test - fun `setDebugMode with null returns error`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("setDebugMode", mapOf()) - plugin.onMethodCall(call, mockResult) - - verify { mockResult.error("MISSING_PARAMETER", "The 'debugMode' parameter is required.", null) } - } - - @Test - fun `setUserAttributeWithString with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("setUserAttributeWithString", mapOf("value" to "test")) - - // Should not throw, just return early - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `setUserAttributeWithString with missing value does not crash`() { + fun `removed isDeeplinkHandled alias is not implemented`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - val call = MethodCall("setUserAttributeWithString", mapOf("key" to "test")) - - // Should not throw, just return early - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `setUserAttributeWithInt with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("setUserAttributeWithInt", mapOf("value" to 42)) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `setUserAttributeWithDouble with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("setUserAttributeWithDouble", mapOf("value" to 3.14)) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `setUserAttributeWithBoolean with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("setUserAttributeWithBoolean", mapOf("value" to true)) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `incrementUserAttribute with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("incrementUserAttribute", mapOf("value" to 5)) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `decrementUserAttribute with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("decrementUserAttribute", mapOf("value" to 5)) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `userAttribute with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("userAttribute", mapOf()) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `clearUserAttribute with missing key does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("clearUserAttribute", mapOf()) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - @Test - fun `revokeDataProcessingConsent with missing purposes does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - - val call = MethodCall("revokeDataProcessingConsent", mapOf()) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - } - - // endregion - - // region Companion Object Tests - - @Test - fun `sendPresentationResult with presentationResult sends correct data for PURCHASED`() { - val mockPlan = mockk(relaxed = true) - val mockPlanMap = mapOf("vendorId" to "test-plan") - - every { mockPlan.toMap() } returns mockPlanMap - every { mockPlan.type } returns DistributionType.RENEWING_SUBSCRIPTION - - PurchaselyFlutterPlugin.presentationResult = mockResult - - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.PURCHASED, mockPlan) - - verify { - mockResult.success(match> { map -> - map["result"] == PLYProductViewResult.PURCHASED.ordinal - }) - } - assertNull(PurchaselyFlutterPlugin.presentationResult) - } - - @Test - fun `sendPresentationResult with presentationResult sends correct data for CANCELLED`() { - val mockPlan = mockk(relaxed = true) - val mockPlanMap = mapOf("vendorId" to "test-plan") - - every { mockPlan.toMap() } returns mockPlanMap - every { mockPlan.type } returns DistributionType.NON_CONSUMABLE - - PurchaselyFlutterPlugin.presentationResult = mockResult - - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.CANCELLED, mockPlan) - - verify { - mockResult.success(match> { map -> - map["result"] == PLYProductViewResult.CANCELLED.ordinal - }) - } - assertNull(PurchaselyFlutterPlugin.presentationResult) - } - - @Test - fun `sendPresentationResult with presentationResult sends correct data for RESTORED`() { - val mockPlan = mockk(relaxed = true) - val mockPlanMap = mapOf("vendorId" to "test-plan") - - every { mockPlan.toMap() } returns mockPlanMap - every { mockPlan.type } returns DistributionType.CONSUMABLE - - PurchaselyFlutterPlugin.presentationResult = mockResult - - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.RESTORED, mockPlan) - - verify { - mockResult.success(match> { map -> - map["result"] == PLYProductViewResult.RESTORED.ordinal - }) - } - assertNull(PurchaselyFlutterPlugin.presentationResult) - } - - @Test - fun `sendPresentationResult with defaultPresentationResult when presentationResult is null`() { - val mockPlan = mockk(relaxed = true) - val mockPlanMap = mapOf("vendorId" to "test-plan") - - every { mockPlan.toMap() } returns mockPlanMap - every { mockPlan.type } returns DistributionType.CONSUMABLE - - PurchaselyFlutterPlugin.presentationResult = null - PurchaselyFlutterPlugin.defaultPresentationResult = mockResult + plugin.onMethodCall(MethodCall("isDeeplinkHandled", mapOf()), mockResult) - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.RESTORED, mockPlan) - - verify { - mockResult.success(match> { map -> - map["result"] == PLYProductViewResult.RESTORED.ordinal - }) - } - // defaultPresentationResult should NOT be set to null - assertNotNull(PurchaselyFlutterPlugin.defaultPresentationResult) - } - - @Test - fun `sendPresentationResult with null plan sends empty map`() { - PurchaselyFlutterPlugin.presentationResult = mockResult - - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.CANCELLED, null) - - verify { - mockResult.success(match> { map -> - (map["plan"] as Map<*, *>).isEmpty() - }) - } - } - - @Test - fun `sendPresentationResult with both results null does nothing`() { - PurchaselyFlutterPlugin.presentationResult = null - PurchaselyFlutterPlugin.defaultPresentationResult = null - - assertDoesNotThrow { - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.CANCELLED, null) - } - } - - @Test - fun `sendPresentationResult clears presentationResult but not defaultPresentationResult`() { - val mockPresentationResult = mockk(relaxed = true) - val mockDefaultResult = mockk(relaxed = true) - - PurchaselyFlutterPlugin.presentationResult = mockPresentationResult - PurchaselyFlutterPlugin.defaultPresentationResult = mockDefaultResult - - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.PURCHASED, null) - - assertNull(PurchaselyFlutterPlugin.presentationResult) - assertNotNull(PurchaselyFlutterPlugin.defaultPresentationResult) - assertEquals(mockDefaultResult, PurchaselyFlutterPlugin.defaultPresentationResult) - } - - @Test - fun `sendPresentationResult prefers presentationResult over defaultPresentationResult`() { - val mockPresentationResult = mockk(relaxed = true) - val mockDefaultResult = mockk(relaxed = true) - - PurchaselyFlutterPlugin.presentationResult = mockPresentationResult - PurchaselyFlutterPlugin.defaultPresentationResult = mockDefaultResult - - PurchaselyFlutterPlugin.sendPresentationResult(PLYProductViewResult.PURCHASED, null) - - verify { mockPresentationResult.success(any()) } - verify(exactly = 0) { mockDefaultResult.success(any()) } - } - - // endregion - - // region ProductActivity Tests - - @Test - fun `ProductActivity relaunch with null flutterActivity returns false`() { - val productActivity = PurchaselyFlutterPlugin.ProductActivity( - presentationId = "test-presentation" - ) - - val result = productActivity.relaunch(null) - - assertFalse(result) - } - - @Test - fun `ProductActivity properties are correctly stored`() { - val productActivity = PurchaselyFlutterPlugin.ProductActivity( - presentationId = "pres-123", - placementId = "place-456", - productId = "prod-789", - planId = "plan-012", - contentId = "content-345", - isFullScreen = true, - loadingBackgroundColor = "#FFFFFF" - ) - - assertEquals("pres-123", productActivity.presentationId) - assertEquals("place-456", productActivity.placementId) - assertEquals("prod-789", productActivity.productId) - assertEquals("plan-012", productActivity.planId) - assertEquals("content-345", productActivity.contentId) - assertTrue(productActivity.isFullScreen) - assertEquals("#FFFFFF", productActivity.loadingBackgroundColor) - } - - @Test - fun `ProductActivity default values are correct`() { - val productActivity = PurchaselyFlutterPlugin.ProductActivity() - - assertNull(productActivity.presentation) - assertNull(productActivity.presentationId) - assertNull(productActivity.placementId) - assertNull(productActivity.productId) - assertNull(productActivity.planId) - assertNull(productActivity.contentId) - assertFalse(productActivity.isFullScreen) - assertNull(productActivity.loadingBackgroundColor) - assertNull(productActivity.activity) - } - - // endregion - - // region FlutterPLYAttribute Enum Tests - - @Test - fun `FlutterPLYAttribute enum has correct ordinal for firebase_app_instance_id`() { - assertEquals(0, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.firebase_app_instance_id.ordinal) - } - - @Test - fun `FlutterPLYAttribute enum has correct ordinal for airship_channel_id`() { - assertEquals(1, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.airship_channel_id.ordinal) - } - - @Test - fun `FlutterPLYAttribute enum has correct ordinal for adjust_id`() { - assertEquals(4, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.adjust_id.ordinal) - } - - @Test - fun `FlutterPLYAttribute enum has correct ordinal for appsflyer_id`() { - assertEquals(5, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.appsflyer_id.ordinal) - } - - @Test - fun `FlutterPLYAttribute enum has correct ordinal for mixpanel_distinct_id`() { - assertEquals(6, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.mixpanel_distinct_id.ordinal) - } - - @Test - fun `FlutterPLYAttribute enum has correct ordinal for oneSignalExternalId`() { - assertEquals(19, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.oneSignalExternalId.ordinal) - } - - @Test - fun `FlutterPLYAttribute enum has correct ordinal for batchCustomUserId`() { - assertEquals(20, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.batchCustomUserId.ordinal) - } - - @Test - fun `FlutterPLYAttribute enum has 21 values`() { - assertEquals(21, PurchaselyFlutterPlugin.Companion.FlutterPLYAttribute.values().size) - } - - // endregion - - // region Presentations Loaded List Tests - - @Test - fun `presentationsLoaded list is empty initially`() { - assertTrue(PurchaselyFlutterPlugin.presentationsLoaded.isEmpty()) - } - - @Test - fun `presentationsLoaded list can be cleared`() { - // Simulate adding something by checking the clear works - PurchaselyFlutterPlugin.presentationsLoaded.clear() - assertTrue(PurchaselyFlutterPlugin.presentationsLoaded.isEmpty()) - } - - // endregion - - // region Paywall Action Handler Tests - - @Test - fun `paywallActionHandler is null initially`() { - assertNull(PurchaselyFlutterPlugin.paywallActionHandler) - } - - @Test - fun `paywallAction is null initially`() { - assertNull(PurchaselyFlutterPlugin.paywallAction) - } - - @Test - fun `paywallActionHandler can be set and invoked`() { - var handlerCalled = false - var receivedValue: Boolean? = null - - PurchaselyFlutterPlugin.paywallActionHandler = { value -> - handlerCalled = true - receivedValue = value - } - - assertNotNull(PurchaselyFlutterPlugin.paywallActionHandler) - - PurchaselyFlutterPlugin.paywallActionHandler?.invoke(true) - - assertTrue(handlerCalled) - assertEquals(true, receivedValue) - } - - @Test - fun `paywallActionHandler can be cleared`() { - PurchaselyFlutterPlugin.paywallActionHandler = { _ -> } - assertNotNull(PurchaselyFlutterPlugin.paywallActionHandler) - - PurchaselyFlutterPlugin.paywallActionHandler = null - assertNull(PurchaselyFlutterPlugin.paywallActionHandler) - } - - // endregion - - // region onProcessAction Tests - - @Test - fun `onProcessAction with handler invokes handler on UI thread`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - plugin.onAttachedToActivity(mockActivityBinding) - - var handlerCalled = false - var handlerValue: Boolean? = null - PurchaselyFlutterPlugin.paywallActionHandler = { value -> - handlerCalled = true - handlerValue = value - } - - every { mockActivity.runOnUiThread(any()) } answers { - firstArg().run() - } - - val call = MethodCall("onProcessAction", mapOf("processAction" to true)) - plugin.onMethodCall(call, mockResult) - - assertTrue(handlerCalled) - assertEquals(true, handlerValue) - verify { mockResult.success(true) } - } - - @Test - fun `onProcessAction with false invokes handler with false`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - plugin.onAttachedToActivity(mockActivityBinding) - - var handlerValue: Boolean? = null - PurchaselyFlutterPlugin.paywallActionHandler = { value -> - handlerValue = value - } - - every { mockActivity.runOnUiThread(any()) } answers { - firstArg().run() - } - - val call = MethodCall("onProcessAction", mapOf("processAction" to false)) - plugin.onMethodCall(call, mockResult) - - assertEquals(false, handlerValue) - verify { mockResult.success(true) } + verify { mockResult.notImplemented() } } @Test - fun `onProcessAction without activity does not crash`() { - plugin.onAttachedToEngine(mockFlutterPluginBinding) - // Note: not attaching to activity - - PurchaselyFlutterPlugin.paywallActionHandler = { _ -> } - - val call = MethodCall("onProcessAction", mapOf("processAction" to true)) + fun `synchronize routes to the native callback and surfaces its result`() { + // The 6.0 native SDK resolves synchronize() through onSuccess/onError + // callbacks. With no store configured (fresh plugin, no Builder), the + // SDK invokes onError(PLYError.NoStoreConfigured) synchronously, which + // the bridge must surface as a result error rather than the old + // fire-and-forget result.success(true). This proves the callback is + // wired end-to-end without mocking the @JvmStatic SDK entry point. + plugin.onMethodCall(MethodCall("synchronize", null), mockResult) - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - verify { mockResult.success(true) } + verify { mockResult.error(eq("-1"), any(), any()) } } @Test - fun `onProcessAction without handler does not crash`() { + fun `removed Android subscription cancellation UI is a no-op`() { plugin.onAttachedToEngine(mockFlutterPluginBinding) - plugin.onAttachedToActivity(mockActivityBinding) - - PurchaselyFlutterPlugin.paywallActionHandler = null - every { mockActivity.runOnUiThread(any()) } answers { - firstArg().run() - } + plugin.onMethodCall(MethodCall("displaySubscriptionCancellationInstruction", null), mockResult) - val call = MethodCall("onProcessAction", mapOf("processAction" to true)) - - assertDoesNotThrow { - plugin.onMethodCall(call, mockResult) - } - verify { mockResult.success(true) } + verify(exactly = 1) { mockResult.success(true) } } - - // endregion - - // region Helper method to assert no exceptions - private inline fun assertDoesNotThrow(block: () -> T): T { - return try { - block() - } catch (e: Exception) { - fail("Expected no exception but got: ${e.message}") - throw e - } - } - - // endregion } diff --git a/purchasely/example/android/app/build.gradle b/purchasely/example/android/app/build.gradle index 09db5fd7..a40350fb 100644 --- a/purchasely/example/android/app/build.gradle +++ b/purchasely/example/android/app/build.gradle @@ -42,7 +42,9 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.purchasely.demo" - minSdkVersion 23 + // The purchasely_flutter plugin requires minSdk 23; flutter.minSdkVersion + // varies by Flutter version (21 on 3.24, 24 on 3.41+), so pin explicitly. + minSdkVersion flutter.minSdkVersion targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -62,6 +64,7 @@ flutter { } dependencies { - implementation 'io.purchasely:google-play:5.7.4' - implementation 'io.purchasely:player:5.7.4' + // Pin to the same native SDK version as the plugin (purchasely/android/build.gradle). + implementation 'io.purchasely:google-play:6.0.0-rc.2' + implementation 'io.purchasely:player:6.0.0-rc.2' } diff --git a/purchasely/example/android/app/src/main/AndroidManifest.xml b/purchasely/example/android/app/src/main/AndroidManifest.xml index f9512ade..cb29de48 100644 --- a/purchasely/example/android/app/src/main/AndroidManifest.xml +++ b/purchasely/example/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + @@ -34,11 +33,6 @@ - - ` | + +### SDK init (per wrapper) + +- **Flutter:** `PurchaselyBuilder.apiKey(K).runningMode(RunningMode.full).logLevel(LogLevel.debug).stores([PLYStore.google]).start()` → `Future` +- **React Native:** `Purchasely.builder(K).runningMode('full').logLevel(LogLevels.debug).stores(['google']).start()` → `Promise` +- **Cordova:** `Purchasely.start(K, ['Google'], false /*storekit1*/, null /*userId*/, Purchasely.LogLevel.DEBUG, Purchasely.RunningMode.full, success, error)` + +### Prerequisites / environment notes + +- A booted Android emulator/device with the example app installed. +- **No Google Play billing on a bare emulator.** `synchronize()` (and any real + purchase) hits `BillingUnavailable`. This is expected and is exercised as the + error path (see T6). Catalog/preload/identity calls do **not** need billing. +- Flutter runs these via `flutter test integration_test/... -d emulator-5554`. + RN: Detox or `@react-native/jest` + a native instrumented driver. Cordova: + an instrumented test or a manual harness page — the JS layer is identical, only + the runner differs. + +--- + +## 2. Public API mapping (Flutter ↔ RN ↔ Cordova) + +| Concept | Flutter | React Native | Cordova | +|---------|---------|--------------|---------| +| Start | `PurchaselyBuilder.apiKey(...).…start()` | `Purchasely.builder(...).…start()` | `Purchasely.start(apiKey, stores, sk1, userId, logLevel, runningMode, ok, err)` | +| Anonymous id | `Purchasely.anonymousUserId` | `Purchasely.getAnonymousUserId()` | `Purchasely.getAnonymousUserId(ok, err)` | +| Is anonymous | `Purchasely.isAnonymous()` | `Purchasely.isAnonymous()` | ❌ not exposed | +| Login / logout | `userLogin(id)` / `userLogout()` | `userLogin(id)` / `userLogout()` | `userLogin(id, ok)` / `userLogout()` | +| Preload | `PresentationBuilder.placement(id).build().preload()` | `Purchasely.presentation.placement(id).build().preload()` | `fetchPresentationForPlacement(placementId, contentId, ok, err)` | +| Display | `request.display([Transition])` | `request.display([transition])` | `presentPresentationForPlacement(placementId, contentId, isFullscreen, ok, err)` | +| Local dismiss | `presentation.close()` | `presentation.close()` | `Purchasely.closePresentation()` | +| All products | `allProducts()` | `allProducts()` | `allProducts(ok, err)` | +| Dynamic offerings | `getDynamicOfferings()` | `getDynamicOfferings()` | ❌ not exposed | +| Synchronize | `synchronize()` → `Future` | `synchronize()` → `Promise` | `synchronize(ok, err)` (resolves on completion) | +| Register interceptor | `interceptAction(kind, handler)` | `interceptAction(kind, handler)` | `setPaywallActionInterceptor(cb)` + `onProcessAction(bool)` (**old model**) | +| Remove interceptor | `removeActionInterceptor(kind)` | `removeActionInterceptor(kind)` | ❌ (no per-kind removal in old model) | +| Remove all interceptors | `removeAllActionInterceptors()` | `removeAllActionInterceptors()` | ❌ | +| Default dismiss handler | `setDefaultPresentationDismissHandler(cb)` | `setDefaultPresentationDismissHandler(cb)` | `setDefaultPresentationDismissHandler(ok, err)` | +| Handle deeplink | `handleDeeplink(url)` → `Future` | `handleDeeplink(url)` → `Promise` | `handleDeeplink(url, ok, err)` | + +> **RN ≈ Flutter (builder API).** Porting to RN is almost 1:1. +> **Cordova is still on the pre-builder imperative API** — there is no +> `PresentationBuilder`, no typed `interceptAction`/`InterceptResult`, and no +> `isAnonymous`/`getDynamicOfferings`. Port the *intent* of each test using the +> imperative entry points; some tests (T2 isAnonymous, T4 dynamic offerings, T7 +> interceptor cleanup) have no Cordova equivalent and should be skipped or +> adapted. + +### Outcome shape + +The dismiss/outcome object delivered to `display()`/`onDismissed`/the default +handler carries 5 fields on every wrapper: +`presentation`, `purchaseResult` (`purchased`/`cancelled`/`restored`/null), +`plan` (typed plan or null), `closeReason`, `error`. + +`closeReason` **wire values**: `button`, `back_system`, `programmatic`, or null. +- Flutter/Android map to enum `CloseReason.{button, backSystem, programmatic}`. +- **iOS parity:** Flutter & RN normalize iOS `interactiveDismiss` → `back_system`. + **Cordova iOS still emits the raw `interactiveDismiss` string** (see its + `Purchasely.js` comment) — assert accordingly per platform when porting. + +--- + +## 3. Test catalog + +Files: +- `dart_android_bridge_test.dart` — T1–T8 (pure-Dart round-trips + local dismiss) +- `interceptor_trigger_test.dart` — T9 (interceptor fired by a real native tap) +- `default_dismiss_handler_test.dart` — T10 (global dismiss handler via deeplink + back) + +### T1 — anonymousUserId returns a non-empty id +- **API:** `anonymousUserId` +- **Action:** read the id after start. +- **Expected:** non-empty string. +- **Port:** RN/Cordova identical (`getAnonymousUserId`). + +### T2 — isAnonymous lifecycle: true → login → false → logout → true +- **API:** `isAnonymous()`, `userLogin(id)`, `userLogout()` +- **Action:** assert anonymous; `userLogin('flutter_it_user')`; assert not anonymous; `userLogout()`; assert anonymous. +- **Expected:** `true → false → true`. `userLogin` resolves a `bool`. +- **Port:** RN identical. **Cordova:** no `isAnonymous` — test only `userLogin`/`userLogout` resolve without error. + +### T3 — preload(placement) returns a typed Presentation +- **API:** `PresentationBuilder.placement(id).build().preload()` +- **Action:** preload `integration_test_audiences`. +- **Expected:** `screenId` non-null (observed `pres_Yzzy4U8bkPAzByL0QS8KJDj6mBWKd6a`), `type == normal`, `plans` is a typed list (observed length 1). Real backend round-trip. +- **Port:** RN identical. **Cordova:** `fetchPresentationForPlacement(...)` → success cb gets a presentation object; assert its `id`/`screenId` and `plans`. + +### T4 — getDynamicOfferings returns a typed list +- **API:** `getDynamicOfferings()` +- **Expected:** typed list (may be empty). +- **Port:** RN identical. **Cordova:** skip (not exposed). + +### T5 — allProducts returns a typed list +- **API:** `allProducts()` +- **Expected:** list of products (observed 5). +- **Port:** RN/Cordova identical. + +### T6 — synchronize() resolves true on success OR throws on store error +- **API:** `synchronize()` +- **Action:** call and handle both outcomes. +- **Expected:** resolves `true` **or** throws/rejects with a platform error. On a + bare emulator it **threw `PlatformException(-1)` BillingUnavailable** — proving + the v6 contract that `synchronize()` now propagates the native error instead of + fire-and-forget. +- **Port (critical for v6):** + - RN: `await synchronize()` resolves `true` or **rejects** — wrap in try/catch. + - Cordova: `synchronize(success, error)` — the **error callback** must fire on + BillingUnavailable; assert one of the two callbacks runs. + +### T7 — interceptor cleanup round-trip (renamed v6 APIs) +- **API:** `interceptAction(kind, handler)`, `removeActionInterceptor(kind)`, `removeAllActionInterceptors()` +- **Action:** register purchase + navigate handlers; `removeActionInterceptor(purchase)`; `removeAllActionInterceptors()`. +- **Expected:** all four MethodChannel round-trips succeed (no throw). +- **Port:** RN identical. **Cordova:** skip (old `setPaywallActionInterceptor`/`onProcessAction` model has no per-kind register/remove). + +### T8 — display + local dismiss (Transition dimension + closeReason) +- **API:** `preload()`, `display(Transition)`, `onPresented`, `presentation.close()` +- **Action:** preload `integration_test_audiences`; `display(Transition(drawer, height: PLYTransitionDimension.percentage(0.6)))`; wait for `onPresented`; `presentation.close()`; await the display future. +- **Expected (observed):** `onPresented` fires (the **v6 Transition dimension** reached native and the drawer rendered); after `close()` the display future resolves with `purchaseResult=cancelled`, **`closeReason=programmatic`**, `plan=null`. +- **Notes:** this is the path that surfaced and verifies the **onDismissed fix** + (see §5). The drawer height uses the v6 dimension model + (`{type:'percentage', value:0.6}` on the wire), not the removed `heightPercentage`. +- **Port:** + - RN: same builder + `Transition` dimension shape; `presentation.close()`; assert outcome `closeReason`. + - Cordova: `presentPresentationForPlacement(placement, null, true, ok, err)` + (the `ok` callback is the dismiss outcome) then `closePresentation()`; assert + the outcome's `closeReason`. Cordova has no drawer-dimension transition arg + (full-screen only) — assert the dismiss outcome rather than the dimension. + +### T9 — purchase interceptor fires with a typed payload on a real tap +- **API:** `interceptAction(purchase, …)`, `interceptAction(closeAll, …)`, `display()` +- **Action:** register `purchase` (capture + return `success`) and `closeAll` + (return `success`, keeps the paywall open — mirrors native Android ACT-01); + display `integration_test_audiences`; **driver taps the native `action:purchase` + button**; poll for the handler to fire. +- **Expected (observed):** the purchase interceptor fires with a typed + `PurchasePayload` → `plan.vendorId=monthly`, `plan.productId=com.purchasely.plus.monthly`; `kind == purchase`. +- **Driver:** `tools/tap_purchase.sh` (uiautomator dump → tap node whose + content-desc contains `action:purchase`). +- **Port:** + - RN: identical JS; reuse the same uiautomator tap driver (the native view is + identical — same `action:purchase` content-desc). + - Cordova: use the **old model** — `setPaywallActionInterceptor(cb)`; in `cb`, + inspect the action; call `onProcessAction(false)` to block; then tap the same + button with the same driver and assert the callback received a purchase action. + +### T10 — global default dismiss handler (SDK-opened presentation) +- **API:** `setDefaultPresentationDismissHandler(cb)`, `handleDeeplink(url)` +- **Action:** register the default handler; `handleDeeplink('ply://ply/placements/integration_test_audiences')` (SDK opens the screen itself); **driver presses system BACK**; poll for the handler. +- **Expected (observed):** the default handler receives the outcome with + **`closeReason=backSystem`** and `presentation.screenId=pres_Yzzy4U8bk…`. Proves + the global handler path + the `back_system` → `backSystem` mapping. +- **Driver:** `tools/press_back.sh` (waits for a paywall, then `adb … input keyevent 4`). +- **Port:** + - RN: identical JS + same back-press driver. + - Cordova: `setDefaultPresentationDismissHandler(ok, err)` + `handleDeeplink(url, ok, err)`; same back-press driver. **iOS caveat:** Cordova iOS reports `closeReason='interactiveDismiss'` (not `back_system`). + +### T11 — default dismiss handler catches a fire-and-forget display() (host-opened) +- **API:** `setDefaultPresentationDismissHandler(cb)`, `preload()`, `display()` (not awaited, no `onDismissed`) +- **Action:** register the default handler; `preload('integration_test_audiences')`; `unawaited(presentation.display())` with **no per-presentation `onDismissed`**; **driver dismisses** (Android system BACK / iOS tap `ply_action_close`); poll for the handler. +- **Expected:** the default handler receives the outcome (Android `closeReason=backSystem`, iOS `button`). Unlike T10 the screen is **host-opened via `display()`**, so the dismissal travels the per-request `onDismissed` event — but since the host set no local handler, the Dart bridge **falls back** to the default handler (`_handleOnDismissed`). This is the regression guard for the dismiss-routing fallback. +- **Files:** `default_dismiss_via_display_test.dart` (Android), `default_dismiss_via_display_ios_test.dart` (iOS). +- **Driver:** `tools/press_back.sh` (Android) / `tools/close_paywall_ios.sh` (iOS). +- **Port:** + - RN: identical builder JS; `display()` without awaiting and without an `onDismissed`; reuse the same drivers. **Requires the RN bridge to implement the same local→default dismiss fallback** (see §5.5). + - Cordova: old imperative model has no per-presentation `onDismissed` to omit; not directly portable — skip or assert via the default handler only. + +### T12 — local onDismissed (+ awaited display()) wins over the default handler +- **API:** `setDefaultPresentationDismissHandler(cb)`, `preload()`, `onDismissed`, `await display()` +- **Action:** register the default handler; build `integration_test_audiences` **with** a local `onDismissed`; `preload()`; **`await display()`** (with a 50 s safety timeout); **driver dismisses** (Android system BACK / iOS tap `ply_action_close`). +- **Expected:** the awaited `display()` future resolves with the outcome, the local `onDismissed` also receives it, and the **default handler stays silent** (`defaultOutcome == null`). This is the complement of T11: it pins that a local handler claims the dismissal so the fallback to the default does NOT happen. (Routing keys on the presence of `onDismissed`; awaiting alone always resolves the future but does not by itself suppress the default — hence the local handler here.) +- **Files:** `local_dismiss_handler_test.dart` (Android), `local_dismiss_handler_ios_test.dart` (iOS). +- **Driver:** `tools/press_back.sh` (Android) / `tools/close_paywall_ios.sh` (iOS). +- **Port:** + - RN: identical builder JS with `onDismissed` set + `await display()`; reuse the same drivers; assert the default handler did not fire. + - Cordova: the imperative `presentPresentationForPlacement(...)` success callback is the per-presentation dismiss outcome — assert it fires and the default handler does not. + +--- + +## 4. Host-side UI drivers + +JS/Dart cannot tap **native** paywall controls, so native interactions are driven +from the host via `adb`/uiautomator, concurrently with the test. Both drivers are +in `tools/` and are wrapper-agnostic (same native views on every wrapper). + +- **`tools/tap_purchase.sh [device]`** — polls `uiautomator dump`, finds the node + whose `content-desc` contains `action:purchase`, taps its center. +- **`tools/press_back.sh [device]`** — waits for any `action:` content-desc + (= a paywall is up), then `input keyevent 4` (system BACK). + +Run pattern: +```bash +bash integration_test/tools/tap_purchase.sh emulator-5554 & +flutter test integration_test/interceptor_trigger_test.dart -d emulator-5554 +``` + +For RN/Cordova, keep these scripts as-is; only the test runner command changes. +(A fully-native alternative is an instrumented test using `UiDevice`/uiautomator +directly, as the native Android `integration-tests` module does.) + +--- + +## 5. Cross-wrapper gotchas (check these when porting) + +1. **`onDismissed` clobbering (native Android).** `dispatchDisplay` / + `Prepared.display(...)` do `onDismissed = callback` for **any non-null + callback** — passing an *empty* lambda CLOBBERS the builder-wired emitter, so + the dismiss event is never delivered and `display()` never resolves. The + Flutter Android plugin had this bug; fixed by passing the **real emitter** as + the dismissal callback on both the loaded and prepared paths. **Verify the RN + and Cordova native Android bridges do the same** — if their `display` + implementation passes an empty/no-op `onDismissed`, dismiss outcomes silently + never reach JS. (T8/T9/T10 will catch a regression.) +2. **`synchronize()` error path on emulator.** Always BillingUnavailable without + Play billing — assert "resolves true OR errors", never "always resolves true". +3. **`closeReason` iOS parity.** Flutter & RN normalize iOS `interactiveDismiss` + → `back_system`. Cordova iOS still emits `interactiveDismiss`. Assert per + platform. +4. **Interceptor chain.** Tapping the purchase button triggers `purchase` → + `close_all`. To keep the paywall open while asserting (T9), intercept + `close_all` too and return `success`/block. +5. **Dismiss routing fallback (local → default).** When a host-opened + `display()` is dismissed and **no** per-presentation/request `onDismissed` is + set, the Flutter Dart bridge falls back to the global + `setDefaultPresentationDismissHandler` (in `_handleOnDismissed`) instead of + dropping the outcome — so a fire-and-forget `display()` (not awaited, no local + handler) still reports centrally. T11 guards this. **When porting:** the RN + bridge must apply the same precedence (local `onDismissed` first, else default + handler); otherwise a fire-and-forget `display()` silently loses its dismissal. diff --git a/purchasely/example/integration_test/INLINE_PAYWALL_CLOSE.md b/purchasely/example/integration_test/INLINE_PAYWALL_CLOSE.md new file mode 100644 index 00000000..8a696729 --- /dev/null +++ b/purchasely/example/integration_test/INLINE_PAYWALL_CLOSE.md @@ -0,0 +1,80 @@ +# Fermeture (croix ✕) d'un paywall inline (`PLYPresentationView`) + +Document issu d'une investigation E2E réelle (émulateur Android `emulator-5554` + +simulateur iOS iPhone 16e, SDK natif `6.0.0-rc.2`), vérifiée **dans l'app réelle** +(`flutter run`) sur les deux plateformes. + +## TL;DR — trois conditions, toutes nécessaires + +Pour qu'un clic sur la croix ✕ d'un paywall **inline** ferme la vue : + +1. **(Plugin) Hybrid composition** — la vue native doit être embarquée en hybrid + composition, sinon **aucune touche** n'atteint le paywall embarqué (ni croix, ni + boutons d'achat). C'est LA cause de fond du « rien ne se passe ». Corrigé dans + `lib/native_view_widget.dart` (voir plus bas). +2. **(Backend) Action `close` sur une vue non-Flow** — la croix doit porter l'action + `close` (fermeture d'écran simple), **pas `close_all`**, et le paywall **ne doit pas + être un Flow**. +3. **(App) Gérer `onCloseRequested` + pop unique** — l'hôte retire la vue. La fermeture + émet `onCloseRequested` **puis** `onDismissed` : ne dépiler qu'**une fois**. + +## Cause de fond #1 — transmission des touches (le vrai bug) + +Le plugin rendait la vue inline via un `AndroidView` simple (mode *virtual display*). +Dans ce mode, les `MotionEvent` ne sont **pas** délivrés de façon fiable aux vues +interactives embarquées : taper la croix **ou** un bouton d'achat ne produisait +**aucune** réaction du SDK (vérifié : aucun event entre `PRESENTATION_VIEWED` et le +`dispose`). + +Correctif (`lib/native_view_widget.dart`) : **hybrid composition** via +`PlatformViewLink` + `PlatformViewsService.initExpensiveAndroidView` (la vue native vit +dans la hiérarchie Android, les touches arrivent nativement) + un `EagerGestureRecognizer` +pour que la platform view capte les gestes. iOS (`UiKitView`) transmet déjà les touches +nativement, aucun changement requis. + +> Note : ce bug n'est **pas** reproductible dans l'environnement `integration_test` +> (le harness ne pilote pas l'input natif des platform views via `adb`/`idb`). +> Vérification de la fermeture = app réelle (`flutter run`). + +## Cause #2 — action de la croix (config Console) + +| Action de la croix ✕ | Inline | `onCloseRequested` ? | +|---|---|---| +| `close` (fermeture simple) | ✅ notifie l'hôte | ✅ oui | +| `close_all` (`closeAllScreens`) | ❌ ferme des activités/écrans modaux → no-op embarqué | ❌ non | +| `open_flow_step` (paywall **Flow**) | ❌ non supporté inline (« must call display()/getFragment() ») | ❌ non | + +Validation objective : un dump uiautomator de la vue inline montre +`content-desc="action:close"` (et `flowId=null` dans `PRESENTATION_LOADED`). + +## Cause #3 — double pop côté app + +La fermeture inline émet **`onCloseRequested`** (le ✕ demande la fermeture) **puis**, +une fois la vue retirée, **`onDismissed`**. Si les deux callbacks dépilent la route, le +second pop ferme aussi l'écran sous-jacent → écran noir. `main.dart` +(`displayPresentationInline`) utilise une garde `popped` pour ne dépiler qu'une fois. + +## Chaîne native Android (référence) + +``` +Builder.onCloseRequested { emit("onCloseRequested") } (plugin, buildPrepared) + → preload() → Loaded.onCloseRequested + → buildView() → viewRequest.onCloseRequested (PLYPresentationDisplayController:70) + → PLYPresentationView lit requestLoaded.onCloseRequested (PLYPresentationView:206) + → close() invoque callbackPaywallCloseRequested (PLYPresentationView:336) +``` +`close()` n'est atteint que par l'action `close` ; `close_all` passe par +`Purchasely.closeAllScreens()` (activités) → no-op pour une vue embarquée. + +## Résultat vérifié (app réelle) + +Android et iOS : ouverture inline → tap croix ✕ → logs +`close requested (inline)` → `popping inline screen` → `dismissed (cancelled)` → +retour à l'écran d'accueil. ✓ + +## Fichiers + +- `lib/native_view_widget.dart` — hybrid composition (Android). +- `example/lib/presentation_screen.dart` — câble `onCloseRequested`/`onDismissed`, contenu au-dessus/en-dessous. +- `example/lib/main.dart` — `displayPresentationInline` avec garde anti-double-pop. +- `example/integration_test/inline_paywall_test.dart` — test E2E de **rendu** inline (le rendu est testable en harness ; la fermeture native ne l'est pas). diff --git a/purchasely/example/integration_test/dart_android_bridge_test.dart b/purchasely/example/integration_test/dart_android_bridge_test.dart new file mode 100644 index 00000000..a05b02ed --- /dev/null +++ b/purchasely/example/integration_test/dart_android_bridge_test.dart @@ -0,0 +1,647 @@ +// End-to-end Dart <-> Android bridge integration tests. +// +// Tests T1-T13 mirror the React Native E2E_TEST_INDEX.md suite, run on a real +// Android device/emulator against the REAL Purchasely backend. +// +// Same API key and placements as the native Android `integration-tests` module +// (com.purchasely.integration.BaseIntegrationTest). +// +// Tests requiring a host driver (T8, T9): +// T8 — (bash integration_test/tools/tap_purchase.sh &) +// T9 — (bash integration_test/tools/press_back.sh &) +// +// Run with: +// flutter test integration_test/dart_android_bridge_test.dart -d emulator-5554 + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .stores([PLYStore.google]).start(); + expect(configured, isTrue, + reason: 'SDK should configure against the real backend'); + }); + + // T1 — Anonymous user ID (non-empty + UUID format) + group('T1 — Identity APIs', () { + testWidgets('anonymousUserId returns a non-empty UUID', (tester) async { + final id = await Purchasely.anonymousUserId; + expect(id, isNotEmpty); + final uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + expect(uuidRegex.hasMatch(id), isTrue, + reason: 'anonymousUserId must be a UUID'); + debugPrint('T1 → anonymousUserId=$id'); + }); + + // T2 — Login / logout cycle + testWidgets('T2 — isAnonymous true → login → false → logout → true', + (tester) async { + expect(await Purchasely.isAnonymous(), isTrue); + await Purchasely.userLogin('flutter_it_user'); + expect(await Purchasely.isAnonymous(), isFalse); + await Purchasely.userLogout(); + expect(await Purchasely.isAnonymous(), isTrue); + }); + }); + + // T3 — Preload: presentation properties + // T4 — Dynamic offerings + // T5 — All products + group('T3-T5 — Catalog / data round-trips', () { + testWidgets('T3 — preload(placement) returns a full PLYPresentation', + (tester) async { + final presentation = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + + expect(presentation.screenId, isNotNull); + expect(presentation.screenId, isNotEmpty); + expect(presentation.placementId, equals(kPlacementAudiences)); + expect(presentation.type, isA()); + expect(presentation.plans, isA>()); + if (presentation.plans.isNotEmpty) { + expect(presentation.plans.first.planVendorId, isNotNull); + expect(presentation.plans.first.planVendorId, isNotEmpty); + } + final firstPlanVendorId = presentation.plans.isNotEmpty + ? presentation.plans.first.planVendorId + : null; + debugPrint('T3 → screenId=${presentation.screenId} ' + 'placementId=${presentation.placementId} ' + 'type=${presentation.type} plans=${presentation.plans.length} ' + 'plans[0].planVendorId=$firstPlanVendorId'); + }); + + testWidgets('T4 — getDynamicOfferings returns a typed list', + (tester) async { + final offerings = await Purchasely.getDynamicOfferings(); + expect(offerings, isA>()); + debugPrint('T4 → ${offerings.length} offering(s)'); + }); + + testWidgets('T5 — allProducts returns a typed list', (tester) async { + final products = await Purchasely.allProducts(); + expect(products, isA>()); + debugPrint('T5 → ${products.length} product(s)'); + }); + }); + + // T6 — Interceptor cleanup round-trip + group('T6 — Action interceptor lifecycle', () { + testWidgets( + 'interceptAction → removeActionInterceptor → removeAllActionInterceptors', + (tester) async { + await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) async => PLYInterceptResult.notHandled, + ); + await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (info, payload) async => PLYInterceptResult.notHandled, + ); + await Purchasely.removeActionInterceptor( + PLYPresentationActionKind.purchase); + await Purchasely.removeAllActionInterceptors(); + expect(true, isTrue); + }); + }); + + // synchronize() — v6-specific (not in RN index but important for Android) + group('synchronize() -> Future (v6)', () { + testWidgets('resolves true or throws PlatformException on billing error', + (tester) async { + try { + final result = await Purchasely.synchronize(); + expect(result, isTrue); + debugPrint('synchronize → $result (success)'); + } on PlatformException catch (e) { + debugPrint('synchronize → PlatformException(${e.code}): ${e.message}'); + } + }); + }); + + // T7 — Display drawer + programmatic close → outcome properties + group('T7 — Display + local dismiss (presentation.close)', () { + testWidgets( + 'display(drawer 60%) → onPresented → close() → outcome with presentation', + (tester) async { + await tester.runAsync(() async { + var presented = false; + PLYPresentationError? presentError; + + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((presentation, error) { + presented = true; + presentError = error; + }).build(); + final presentation = await request.preload(); + + final displayFuture = presentation.display(const PLYTransition( + type: PLYTransitionType.drawer, + height: PLYTransitionDimension.percentage(0.6), + dismissible: true, + )); + + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, + reason: 'drawer should present with the v6 dimension transition'); + expect(presentError, isNull); + + await presentation.close(); + final outcome = + await displayFuture.timeout(const Duration(seconds: 15)); + + expect(outcome, isA()); + expect(outcome.error, isNull); + expect( + outcome.closeReason, + anyOf(PLYCloseReason.programmatic, PLYCloseReason.button, + PLYCloseReason.backSystem), + ); + expect(outcome.plan, anyOf(isNull, isA())); + // v6 — outcome carries presentation metadata (RN T7 steps 7-8) + expect(outcome.presentation?.screenId, isNotNull); + expect(outcome.presentation?.screenId, isNotEmpty); + expect(outcome.presentation?.placementId, isNotNull); + expect(outcome.presentation?.placementId, isNotEmpty); + debugPrint('T7 → closeReason=${outcome.closeReason} ' + 'screenId=${outcome.presentation?.screenId} ' + 'placementId=${outcome.presentation?.placementId}'); + }); + }); + }); + + // T8 — Purchase interceptor fires on real tap (host driver: tap_purchase.sh) + // Covered by integration_test/interceptor_trigger_test.dart + + // T9 — Default dismiss handler + deeplink + BACK (host driver: press_back.sh) + // Covered by integration_test/default_dismiss_handler_test.dart + + // T10 — addEventListener → PRESENTATION_VIEWED + group('T10 — Events: PRESENTATION_VIEWED', () { + testWidgets( + 'listenToEvents fires PRESENTATION_VIEWED when a presentation renders', + (tester) async { + await tester.runAsync(() async { + // Accept PRESENTATION_LOADED or PRESENTATION_VIEWED — the SDK may + // deduplicate PRESENTATION_VIEWED per session when the same paywall was + // already shown in T7. PRESENTATION_LOADED fires unconditionally. + PLYEvent? paywallEvent; + + Purchasely.listenToEvents((event) { + if (event.name == PLYEventName.PRESENTATION_VIEWED || + event.name == PLYEventName.PRESENTATION_LOADED) { + paywallEvent ??= event; + } + }); + + final request = + PLYPresentationBuilder.placement(kPlacementAudiences).build(); + final presentation = await request.preload(); + // ignore: unawaited_futures + presentation.display(const PLYTransition.fullScreen()); + + final sw = Stopwatch()..start(); + while ( + paywallEvent == null && sw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + + expect(paywallEvent, isNotNull, + reason: 'PRESENTATION_VIEWED must fire when the paywall renders'); + expect(paywallEvent!.properties.sdk_version, isNotNull); + expect(paywallEvent!.properties.sdk_version, isNotEmpty); + debugPrint('T10 → ${paywallEvent!.name} ' + 'sdk_version=${paywallEvent!.properties.sdk_version}'); + + await presentation.close(); + Purchasely.stopListeningToEvents(); + }); + }); + }); + + // T11 — PRESENTATION_CLOSED → source_identifier + displayed_presentation + group('T11 — Events: PRESENTATION_CLOSED', () { + testWidgets( + 'PRESENTATION_CLOSED fires with source_identifier and displayed_presentation', + (tester) async { + await tester.runAsync(() async { + PLYEvent? viewedEvent; + PLYEvent? closedEvent; + + Purchasely.listenToEvents((event) { + // Accept LOADED or VIEWED (VIEWED may be deduped by the SDK per session). + if (event.name == PLYEventName.PRESENTATION_VIEWED || + event.name == PLYEventName.PRESENTATION_LOADED) { + viewedEvent ??= event; + } else if (event.name == PLYEventName.PRESENTATION_CLOSED) { + closedEvent ??= event; + } + }); + + final request = + PLYPresentationBuilder.placement(kPlacementAudiences).build(); + final presentation = await request.preload(); + // ignore: unawaited_futures + presentation.display(const PLYTransition.fullScreen()); + + final viewedSw = Stopwatch()..start(); + while (viewedEvent == null && + viewedSw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(viewedEvent, isNotNull, + reason: 'A paywall event must fire before testing CLOSED'); + + await presentation.close(); + + final closedSw = Stopwatch()..start(); + while (closedEvent == null && + closedSw.elapsed < const Duration(seconds: 10)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + + expect(closedEvent, isNotNull, + reason: 'PRESENTATION_CLOSED must fire after programmatic close'); + // source_identifier is the placement_id in the Flutter event contract. + expect(closedEvent!.properties.source_identifier, isNotNull); + expect(closedEvent!.properties.source_identifier, isNotEmpty); + expect(closedEvent!.properties.displayed_presentation, isNotNull); + expect(closedEvent!.properties.displayed_presentation, isNotEmpty); + debugPrint('T11 → PRESENTATION_CLOSED ' + 'source_identifier=${closedEvent!.properties.source_identifier} ' + 'displayed_presentation=${closedEvent!.properties.displayed_presentation}'); + + Purchasely.stopListeningToEvents(); + }); + }); + }); + + // T12 — Programmatic close does NOT trigger close/closeAll interceptors + group('T12 — Programmatic close bypasses interceptors', () { + testWidgets( + 'presentation.close() does not route through close or closeAll interceptors', + (tester) async { + await tester.runAsync(() async { + var interceptorCalled = false; + + await Purchasely.interceptAction( + PLYPresentationActionKind.close, + (info, payload) async { + interceptorCalled = true; + return PLYInterceptResult.notHandled; + }, + ); + await Purchasely.interceptAction( + PLYPresentationActionKind.closeAll, + (info, payload) async { + interceptorCalled = true; + return PLYInterceptResult.notHandled; + }, + ); + + final request = + PLYPresentationBuilder.placement(kPlacementAudiences).build(); + final presentation = await request.preload(); + // ignore: unawaited_futures + presentation.display(const PLYTransition.fullScreen()); + + // Allow the paywall to render before closing. + await Future.delayed(const Duration(seconds: 3)); + await presentation.close(); + await Future.delayed(const Duration(seconds: 2)); + + expect(interceptorCalled, isFalse, + reason: + 'programmatic close() must NOT route through action interceptors'); + debugPrint('T12 → interceptorCalled=$interceptorCalled ✓'); + + await Purchasely.removeAllActionInterceptors(); + }); + }); + }); + + // T13 — User attributes: set / get / clear + group('T13 — User attributes', () { + testWidgets( + 'setUserAttribute* / userAttribute / clearUserAttribute round-trip', + (tester) async { + await tester.runAsync(() async { + await Purchasely.setUserAttributeWithString('e2e_str', 'hello_flutter'); + await Purchasely.setUserAttributeWithInt('e2e_num', 42); + await Purchasely.setUserAttributeWithBoolean('e2e_bool', true); + + await Future.delayed(const Duration(milliseconds: 300)); + + final strVal = await Purchasely.userAttribute('e2e_str'); + expect(strVal, equals('hello_flutter')); + + final numVal = await Purchasely.userAttribute('e2e_num'); + expect(numVal, equals(42)); + + final boolVal = await Purchasely.userAttribute('e2e_bool'); + expect(boolVal, equals(true)); + + Purchasely.clearUserAttribute('e2e_str'); + Purchasely.clearUserAttribute('e2e_num'); + Purchasely.clearUserAttribute('e2e_bool'); + + await Future.delayed(const Duration(milliseconds: 300)); + + final strAfter = await Purchasely.userAttribute('e2e_str'); + expect(strAfter, isNull); + + final numAfter = await Purchasely.userAttribute('e2e_num'); + expect(numAfter, isNull); + + debugPrint('T13 → str=$strVal num=$numVal bool=$boolVal ' + '→ after clear: str=$strAfter num=$numAfter'); + }); + }); + }); + + // T14 — Extended user attribute types: double, date, arrays + group('T14 — User attributes: types étendus', () { + testWidgets( + 'double / date / string-array / int-array / boolean-array round-trip', + (tester) async { + await tester.runAsync(() async { + await Purchasely.setUserAttributeWithDouble('e2e_dbl', 3.14); + await Purchasely.setUserAttributeWithDate( + 'e2e_date', DateTime.utc(2024, 6, 15, 12, 0, 0)); + await Purchasely.setUserAttributeWithStringArray( + 'e2e_str_arr', ['alpha', 'beta', 'gamma']); + await Purchasely.setUserAttributeWithIntArray( + 'e2e_int_arr', [10, 20, 30]); + await Purchasely.setUserAttributeWithBooleanArray( + 'e2e_bool_arr', [true, false, true]); + + await Future.delayed(const Duration(milliseconds: 400)); + + final rawDbl = await Purchasely.userAttribute('e2e_dbl'); + expect(rawDbl, isNotNull); + expect((rawDbl as num).toDouble(), closeTo(3.14, 0.01)); + + final dateVal = await Purchasely.userAttribute('e2e_date'); + expect(dateVal, isA()); + final dt = dateVal as DateTime; + expect(dt.year, equals(2024)); + expect(dt.month, equals(6)); + expect(dt.day, equals(15)); + + final strArr = await Purchasely.userAttribute('e2e_str_arr'); + expect(strArr, isA()); + expect((strArr as List).length, equals(3)); + + final intArr = await Purchasely.userAttribute('e2e_int_arr'); + expect(intArr, isA()); + expect((intArr as List).length, equals(3)); + + final boolArr = await Purchasely.userAttribute('e2e_bool_arr'); + expect(boolArr, isA()); + expect((boolArr as List).length, equals(3)); + + for (final k in [ + 'e2e_dbl', + 'e2e_date', + 'e2e_str_arr', + 'e2e_int_arr', + 'e2e_bool_arr' + ]) { + Purchasely.clearUserAttribute(k); + } + debugPrint('T14 → dbl=${(rawDbl).toDouble()} ' + 'date=${dt.toIso8601String()} ' + 'strArr=$strArr ✓'); + }); + }); + }); + + // T15 — Bulk attribute operations: userAttributes(), clearUserAttributes(), clearBuiltInAttributes() + group('T15 — User attributes: opérations bulk', () { + testWidgets( + 'userAttributes() returns map / clearUserAttributes() vide tout / clearBuiltInAttributes() no-throw', + (tester) async { + await tester.runAsync(() async { + await Purchasely.setUserAttributeWithString('bulk_a', 'hello'); + await Purchasely.setUserAttributeWithInt('bulk_b', 99); + await Future.delayed(const Duration(milliseconds: 300)); + + final all = await Purchasely.userAttributes(); + expect(all, isA()); + expect(all.containsKey('bulk_a'), isTrue, + reason: 'bulk_a doit apparaître dans userAttributes()'); + expect(all['bulk_a'], equals('hello')); + + Purchasely.clearUserAttributes(); + await Future.delayed(const Duration(milliseconds: 300)); + + final afterClear = await Purchasely.userAttribute('bulk_a'); + expect(afterClear, isNull, + reason: 'clearUserAttributes doit supprimer tous les attributs'); + + Purchasely.clearBuiltInAttributes(); + debugPrint('T15 → userAttributes=${all.length} entrées, ' + 'clearUserAttributes ✓, clearBuiltInAttributes no-throw ✓'); + }); + }); + }); + + // T16 — Increment / decrement + group('T16 — User attributes: increment / decrement', () { + testWidgets( + 'incrementUserAttribute / decrementUserAttribute modifient le compteur', + (tester) async { + await tester.runAsync(() async { + Purchasely.clearUserAttribute('e2e_counter'); + await Future.delayed(const Duration(milliseconds: 300)); + + await Purchasely.incrementUserAttribute('e2e_counter', value: 7); + await Future.delayed(const Duration(milliseconds: 300)); + final v1 = await Purchasely.userAttribute('e2e_counter'); + expect(v1, isNotNull); + + await Purchasely.incrementUserAttribute('e2e_counter', value: 3); + await Future.delayed(const Duration(milliseconds: 300)); + final v2 = await Purchasely.userAttribute('e2e_counter'); + expect(v2, isNotNull); + if (v1 is num && v2 is num) { + expect((v2).toDouble(), greaterThan((v1).toDouble()), + reason: 'increment doit augmenter la valeur'); + } + + await Purchasely.decrementUserAttribute('e2e_counter', value: 4); + await Future.delayed(const Duration(milliseconds: 300)); + final v3 = await Purchasely.userAttribute('e2e_counter'); + expect(v3, isNotNull); + if (v2 is num && v3 is num) { + expect((v3).toDouble(), lessThan((v2).toDouble()), + reason: 'decrement doit diminuer la valeur'); + } + + Purchasely.clearUserAttribute('e2e_counter'); + debugPrint('T16 → counter: v1=$v1 → +3 → v2=$v2 → -4 → v3=$v3 ✓'); + }); + }); + }); + + // T17 — Catalogue: productWithIdentifier / planWithIdentifier / isEligibleForIntroOffer + group( + 'T17 — Catalogue: productWithIdentifier / planWithIdentifier / isEligibleForIntroOffer', + () { + testWidgets('lookup par vendorId + eligibility check', (tester) async { + await tester.runAsync(() async { + final products = await Purchasely.allProducts(); + expect(products, isNotEmpty, + reason: + 'Au moins un produit est nécessaire pour tester le catalogue'); + + final product = products.first; + final fetched = + await Purchasely.productWithIdentifier(product.vendorId); + expect(fetched.vendorId, equals(product.vendorId)); + expect(fetched.name, isNotEmpty); + debugPrint('T17 → productWithIdentifier=${fetched.vendorId}'); + + final plan = product.plans.isNotEmpty ? product.plans.first : null; + final planId = plan?.vendorId; + if (planId != null) { + final fetchedPlan = await Purchasely.planWithIdentifier(planId); + expect(fetchedPlan, isNotNull); + expect(fetchedPlan!.vendorId, equals(planId)); + debugPrint('T17 → planWithIdentifier=${fetchedPlan.vendorId}'); + + final isEligible = await Purchasely.isEligibleForIntroOffer(planId); + expect(isEligible, isA()); + debugPrint('T17 → isEligibleForIntroOffer=$isEligible'); + } + }); + }); + }); + + // T18 — Dynamic offerings: set / get / remove / clear + group('T18 — Dynamic offerings: CRUD', () { + testWidgets( + 'setDynamicOffering → getDynamicOfferings → removeDynamicOffering → clearDynamicOfferings', + (tester) async { + await tester.runAsync(() async { + final presentation = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + final planVendorId = presentation.plans.isNotEmpty + ? presentation.plans.first.planVendorId + : null; + expect(planVendorId, isNotNull, + reason: 'Un plan est nécessaire pour tester setDynamicOffering'); + + final ok = await Purchasely.setDynamicOffering( + PLYDynamicOffering('e2e_ref', planVendorId!, null), + ); + expect(ok, isA()); + + await Future.delayed(const Duration(milliseconds: 300)); + final offerings = await Purchasely.getDynamicOfferings(); + expect(offerings, isA>()); + + Purchasely.removeDynamicOffering('e2e_ref'); + await Future.delayed(const Duration(milliseconds: 300)); + Purchasely.clearDynamicOfferings(); + + debugPrint('T18 → setDynamicOffering=$ok ' + 'offerings=${offerings.length} ' + 'remove+clear ✓'); + }); + }); + }); + + // T19 — Builder screen(id) + variantes de transition (modal, popin) + group('T19 — Builder screen(id) + transitions: modal / popin', () { + testWidgets('PLYPresentationBuilder.screen(id) fonctionne + modal + popin', + (tester) async { + await tester.runAsync(() async { + final byPlacement = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + final screenId = byPlacement.screenId; + expect(screenId, isNotNull); + + // Variante screen(id) → modal + final byScreen = + await PLYPresentationBuilder.screen(screenId!).build().preload(); + expect(byScreen.screenId, isNotNull); + + final f1 = byScreen.display(const PLYTransition.modal()); + await Future.delayed(const Duration(seconds: 2)); + await byScreen.close(); + final outcome1 = await f1.timeout(const Duration(seconds: 10)); + expect(outcome1.presentation?.screenId, isNotNull); + debugPrint('T19 → screen($screenId) modal → ${outcome1.closeReason}'); + + // Variante popin + final byScreen2 = + await PLYPresentationBuilder.screen(screenId).build().preload(); + final f2 = byScreen2.display(const PLYTransition.popin( + width: PLYTransitionDimension.pixel(320), + height: PLYTransitionDimension.percentage(0.6), + )); + await Future.delayed(const Duration(seconds: 2)); + await byScreen2.close(); + final outcome2 = await f2.timeout(const Duration(seconds: 10)); + expect(outcome2.presentation?.screenId, isNotNull); + debugPrint('T19 → popin → ${outcome2.closeReason}'); + }); + }); + }); + + // T20 — Config setters: smoke test (allowDeeplink, allowCampaigns, setLanguage, + // setThemeMode, setLogLevel, setDebugMode, revokeDataProcessingConsent, + // handleDeeplink) + group('T20 — Config setters: smoke test', () { + testWidgets( + 'allowDeeplink / allowCampaigns / setLanguage / setThemeMode / setLogLevel / ' + 'setDebugMode / revokeDataProcessingConsent / handleDeeplink ne throw pas', + (tester) async { + await tester.runAsync(() async { + await Purchasely.allowDeeplink(true); + await Purchasely.allowDeeplink(false); + await Purchasely.allowCampaigns(true); + await Purchasely.allowCampaigns(false); + await Purchasely.setLanguage('en'); + await Purchasely.setThemeMode(PLYThemeMode.system); + await Purchasely.setLogLevel(PLYLogLevel.debug); + await Purchasely.setDebugMode(false); + Purchasely.revokeDataProcessingConsent( + [PLYDataProcessingPurpose.analytics]); + + // Sur iOS le SDK fait un aller-retour réseau avant de rejeter l'URL → timeout court. + final handled = await Purchasely.handleDeeplink( + 'https://example.com/not-a-ply-link') + .timeout(const Duration(seconds: 5), onTimeout: () => false); + expect(handled, isA()); + debugPrint( + 'T20 → handleDeeplink=$handled, all config setters no-throw ✓'); + }); + }); + }); +} diff --git a/purchasely/example/integration_test/dart_ios_bridge_test.dart b/purchasely/example/integration_test/dart_ios_bridge_test.dart new file mode 100644 index 00000000..6af07faa --- /dev/null +++ b/purchasely/example/integration_test/dart_ios_bridge_test.dart @@ -0,0 +1,644 @@ +// End-to-end Dart <-> iOS bridge integration tests. +// +// Tests T1-T13 mirror the React Native E2E_TEST_INDEX.md suite, run on a real +// iOS device or simulator against the REAL Purchasely backend. +// +// Tests requiring a host driver (T8, T9): +// T8 — (bash integration_test/tools/tap_purchase_ios.sh &) # idb tap +// T9 — (bash integration_test/tools/swipe_dismiss_ios.sh &) # idb swipe +// +// Run with: +// flutter test integration_test/dart_ios_bridge_test.dart \ +// -d "iPhone 16" + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + bool configured = false; + try { + configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .storekitVersion(PLYStorekitVersion.storeKit2) + .start() + .timeout(const Duration(seconds: 120), + onTimeout: () => + throw StateError('Purchasely.start() timed out after 120s')); + } catch (e) { + debugPrint('SETUP → start() error: $e'); + rethrow; + } + debugPrint('SETUP → configured=$configured'); + expect(configured, isTrue, + reason: 'SDK should configure against the real backend'); + }); + + // T1 — Anonymous user ID (non-empty + UUID format) + group('T1 — Identity APIs', () { + testWidgets('anonymousUserId returns a non-empty UUID', (tester) async { + final id = await Purchasely.anonymousUserId; + expect(id, isNotEmpty); + final uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + expect(uuidRegex.hasMatch(id), isTrue, + reason: 'anonymousUserId must be a UUID'); + debugPrint('T1 → anonymousUserId=$id'); + }); + + // T2 — Login / logout cycle + testWidgets('T2 — isAnonymous true → login → false → logout → true', + (tester) async { + expect(await Purchasely.isAnonymous(), isTrue); + await Purchasely.userLogin('flutter_ios_it_user'); + expect(await Purchasely.isAnonymous(), isFalse); + await Purchasely.userLogout(); + expect(await Purchasely.isAnonymous(), isTrue); + }); + }); + + // T3 — Preload: presentation properties + // T4 — Dynamic offerings + // T5 — All products + group('T3-T5 — Catalog / data round-trips', () { + testWidgets('T3 — preload(placement) returns a full PLYPresentation', + (tester) async { + final presentation = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + + expect(presentation.screenId, isNotNull); + expect(presentation.screenId, isNotEmpty); + expect(presentation.placementId, equals(kPlacementAudiences)); + expect(presentation.type, isA()); + expect(presentation.plans, isA>()); + if (presentation.plans.isNotEmpty) { + expect(presentation.plans.first.planVendorId, isNotNull); + expect(presentation.plans.first.planVendorId, isNotEmpty); + } + final firstPlanVendorId = presentation.plans.isNotEmpty + ? presentation.plans.first.planVendorId + : null; + debugPrint('T3 → screenId=${presentation.screenId} ' + 'placementId=${presentation.placementId} ' + 'type=${presentation.type} plans=${presentation.plans.length} ' + 'plans[0].planVendorId=$firstPlanVendorId'); + }); + + testWidgets('T4 — getDynamicOfferings returns a typed list', + (tester) async { + final offerings = await Purchasely.getDynamicOfferings(); + expect(offerings, isA>()); + debugPrint('T4 → ${offerings.length} offering(s)'); + }); + + testWidgets('T5 — allProducts returns a typed list', (tester) async { + final products = await Purchasely.allProducts(); + expect(products, isA>()); + debugPrint('T5 → ${products.length} product(s)'); + }); + }); + + // T6 — Interceptor cleanup round-trip + group('T6 — Action interceptor lifecycle', () { + testWidgets( + 'interceptAction → removeActionInterceptor → removeAllActionInterceptors', + (tester) async { + await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) async => PLYInterceptResult.notHandled, + ); + await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (info, payload) async => PLYInterceptResult.notHandled, + ); + await Purchasely.removeActionInterceptor( + PLYPresentationActionKind.purchase); + await Purchasely.removeAllActionInterceptors(); + expect(true, isTrue); + }); + }); + + // T7 — Display drawer + programmatic close → outcome properties + group('T7 — Display + local dismiss (presentation.close)', () { + testWidgets( + 'display(drawer 60%) → onPresented → close() → outcome with presentation', + (tester) async { + await tester.runAsync(() async { + var presented = false; + PLYPresentationError? presentError; + + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((presentation, error) { + presented = true; + presentError = error; + }).build(); + final presentation = await request.preload(); + + final displayFuture = presentation.display(const PLYTransition( + type: PLYTransitionType.drawer, + height: PLYTransitionDimension.percentage(0.6), + dismissible: true, + )); + + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, + reason: 'drawer should present with the v6 dimension transition'); + expect(presentError, isNull); + + await presentation.close(); + final outcome = + await displayFuture.timeout(const Duration(seconds: 15)); + + expect(outcome, isA()); + expect(outcome.error, isNull); + expect( + outcome.closeReason, + anyOf(PLYCloseReason.programmatic, PLYCloseReason.button, + PLYCloseReason.backSystem), + ); + expect(outcome.plan, anyOf(isNull, isA())); + expect(outcome.presentation?.screenId, isNotNull); + expect(outcome.presentation?.screenId, isNotEmpty); + expect(outcome.presentation?.placementId, isNotNull); + expect(outcome.presentation?.placementId, isNotEmpty); + debugPrint('T7 → closeReason=${outcome.closeReason} ' + 'screenId=${outcome.presentation?.screenId} ' + 'placementId=${outcome.presentation?.placementId}'); + }); + }); + }); + + // T8 — Purchase interceptor fires on real tap + // Host driver: integration_test/tools/tap_purchase_ios.sh (idb tap) + // Covered by integration_test/interceptor_trigger_ios_test.dart + + // T9 — Default dismiss handler + deeplink + swipe-dismiss + // Host driver: integration_test/tools/swipe_dismiss_ios.sh (idb swipe) + // Covered by integration_test/default_dismiss_handler_ios_test.dart + + // T10 — addEventListener → PRESENTATION_VIEWED + group('T10 — Events: PRESENTATION_VIEWED', () { + testWidgets( + 'listenToEvents fires PRESENTATION_VIEWED when a presentation renders', + (tester) async { + await tester.runAsync(() async { + // Accept PRESENTATION_LOADED or PRESENTATION_VIEWED — the SDK may + // deduplicate PRESENTATION_VIEWED per session when the same paywall was + // already shown in T7. PRESENTATION_LOADED fires unconditionally. + PLYEvent? paywallEvent; + Purchasely.listenToEvents((event) { + if (event.name == PLYEventName.PRESENTATION_VIEWED || + event.name == PLYEventName.PRESENTATION_LOADED) { + paywallEvent ??= event; + } + }); + + final request = + PLYPresentationBuilder.placement(kPlacementAudiences).build(); + final presentation = await request.preload(); + // ignore: unawaited_futures + presentation.display(const PLYTransition.fullScreen()); + + final sw = Stopwatch()..start(); + while ( + paywallEvent == null && sw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + + expect(paywallEvent, isNotNull, + reason: 'PRESENTATION_VIEWED must fire when the paywall renders'); + expect(paywallEvent!.properties.sdk_version, isNotNull); + expect(paywallEvent!.properties.sdk_version, isNotEmpty); + debugPrint('T10 → ${paywallEvent!.name} ' + 'sdk_version=${paywallEvent!.properties.sdk_version}'); + + await presentation.close(); + Purchasely.stopListeningToEvents(); + }); + }); + }); + + // T11 — PRESENTATION_CLOSED → source_identifier + displayed_presentation + group('T11 — Events: PRESENTATION_CLOSED', () { + testWidgets( + 'PRESENTATION_CLOSED fires with source_identifier and displayed_presentation', + (tester) async { + await tester.runAsync(() async { + PLYEvent? viewedEvent; + PLYEvent? closedEvent; + + Purchasely.listenToEvents((event) { + // Accept LOADED or VIEWED (VIEWED may be deduped by the SDK per session). + if (event.name == PLYEventName.PRESENTATION_VIEWED || + event.name == PLYEventName.PRESENTATION_LOADED) { + viewedEvent ??= event; + } else if (event.name == PLYEventName.PRESENTATION_CLOSED) { + closedEvent ??= event; + } + }); + + final request = + PLYPresentationBuilder.placement(kPlacementAudiences).build(); + final presentation = await request.preload(); + // ignore: unawaited_futures + presentation.display(const PLYTransition.fullScreen()); + + final viewedSw = Stopwatch()..start(); + while (viewedEvent == null && + viewedSw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(viewedEvent, isNotNull, + reason: 'A paywall event must fire before testing CLOSED'); + + await presentation.close(); + + final closedSw = Stopwatch()..start(); + while (closedEvent == null && + closedSw.elapsed < const Duration(seconds: 10)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + + expect(closedEvent, isNotNull, + reason: 'PRESENTATION_CLOSED must fire after programmatic close'); + expect(closedEvent!.properties.source_identifier, isNotNull); + expect(closedEvent!.properties.source_identifier, isNotEmpty); + expect(closedEvent!.properties.displayed_presentation, isNotNull); + expect(closedEvent!.properties.displayed_presentation, isNotEmpty); + debugPrint('T11 → PRESENTATION_CLOSED ' + 'source_identifier=${closedEvent!.properties.source_identifier} ' + 'displayed_presentation=${closedEvent!.properties.displayed_presentation}'); + + Purchasely.stopListeningToEvents(); + }); + }); + }); + + // T12 — Programmatic close does NOT trigger close/closeAll interceptors + group('T12 — Programmatic close bypasses interceptors', () { + testWidgets( + 'presentation.close() does not route through close or closeAll interceptors', + (tester) async { + await tester.runAsync(() async { + var interceptorCalled = false; + + await Purchasely.interceptAction( + PLYPresentationActionKind.close, + (info, payload) async { + interceptorCalled = true; + return PLYInterceptResult.notHandled; + }, + ); + await Purchasely.interceptAction( + PLYPresentationActionKind.closeAll, + (info, payload) async { + interceptorCalled = true; + return PLYInterceptResult.notHandled; + }, + ); + + final request = + PLYPresentationBuilder.placement(kPlacementAudiences).build(); + final presentation = await request.preload(); + // ignore: unawaited_futures + presentation.display(const PLYTransition.fullScreen()); + + await Future.delayed(const Duration(seconds: 3)); + await presentation.close(); + await Future.delayed(const Duration(seconds: 2)); + + expect(interceptorCalled, isFalse, + reason: + 'programmatic close() must NOT route through action interceptors'); + debugPrint('T12 → interceptorCalled=$interceptorCalled ✓'); + + await Purchasely.removeAllActionInterceptors(); + }); + }); + }); + + // T13 — User attributes: set / get / clear + group('T13 — User attributes', () { + testWidgets( + 'setUserAttribute* / userAttribute / clearUserAttribute round-trip', + (tester) async { + await tester.runAsync(() async { + await Purchasely.setUserAttributeWithString( + 'e2e_str', 'hello_flutter_ios'); + await Purchasely.setUserAttributeWithInt('e2e_num', 42); + await Purchasely.setUserAttributeWithBoolean('e2e_bool', true); + + await Future.delayed(const Duration(milliseconds: 300)); + + final strVal = await Purchasely.userAttribute('e2e_str'); + expect(strVal, equals('hello_flutter_ios')); + + final numVal = await Purchasely.userAttribute('e2e_num'); + expect(numVal, equals(42)); + + final boolVal = await Purchasely.userAttribute('e2e_bool'); + expect(boolVal, equals(true)); + + Purchasely.clearUserAttribute('e2e_str'); + Purchasely.clearUserAttribute('e2e_num'); + Purchasely.clearUserAttribute('e2e_bool'); + + await Future.delayed(const Duration(milliseconds: 300)); + + final strAfter = await Purchasely.userAttribute('e2e_str'); + expect(strAfter, isNull); + + final numAfter = await Purchasely.userAttribute('e2e_num'); + expect(numAfter, isNull); + + debugPrint('T13 → str=$strVal num=$numVal bool=$boolVal ' + '→ after clear: str=$strAfter num=$numAfter'); + }); + }); + }); + + // T14 — Extended user attribute types: double, date, arrays + group('T14 — User attributes: types étendus', () { + testWidgets( + 'double / date / string-array / int-array / boolean-array round-trip', + (tester) async { + await tester.runAsync(() async { + await Purchasely.setUserAttributeWithDouble('e2e_dbl', 3.14); + await Purchasely.setUserAttributeWithDate( + 'e2e_date', DateTime.utc(2024, 6, 15, 12, 0, 0)); + await Purchasely.setUserAttributeWithStringArray( + 'e2e_str_arr', ['alpha', 'beta', 'gamma']); + await Purchasely.setUserAttributeWithIntArray( + 'e2e_int_arr', [10, 20, 30]); + await Purchasely.setUserAttributeWithBooleanArray( + 'e2e_bool_arr', [true, false, true]); + + await Future.delayed(const Duration(milliseconds: 400)); + + final rawDbl = await Purchasely.userAttribute('e2e_dbl'); + expect(rawDbl, isNotNull); + expect((rawDbl as num).toDouble(), closeTo(3.14, 0.01)); + + final dateVal = await Purchasely.userAttribute('e2e_date'); + expect(dateVal, isA()); + final dt = dateVal as DateTime; + expect(dt.year, equals(2024)); + expect(dt.month, equals(6)); + expect(dt.day, equals(15)); + + final strArr = await Purchasely.userAttribute('e2e_str_arr'); + expect(strArr, isA()); + expect((strArr as List).length, equals(3)); + + final intArr = await Purchasely.userAttribute('e2e_int_arr'); + expect(intArr, isA()); + expect((intArr as List).length, equals(3)); + + final boolArr = await Purchasely.userAttribute('e2e_bool_arr'); + expect(boolArr, isA()); + expect((boolArr as List).length, equals(3)); + + for (final k in [ + 'e2e_dbl', + 'e2e_date', + 'e2e_str_arr', + 'e2e_int_arr', + 'e2e_bool_arr' + ]) { + Purchasely.clearUserAttribute(k); + } + debugPrint('T14 → dbl=${(rawDbl).toDouble()} ' + 'date=${dt.toIso8601String()} ' + 'strArr=$strArr ✓'); + }); + }); + }); + + // T15 — Bulk attribute operations: userAttributes(), clearUserAttributes(), clearBuiltInAttributes() + group('T15 — User attributes: opérations bulk', () { + testWidgets( + 'userAttributes() returns map / clearUserAttributes() vide tout / clearBuiltInAttributes() no-throw', + (tester) async { + await tester.runAsync(() async { + await Purchasely.setUserAttributeWithString('bulk_a', 'hello'); + await Purchasely.setUserAttributeWithInt('bulk_b', 99); + await Future.delayed(const Duration(milliseconds: 300)); + + final all = await Purchasely.userAttributes(); + expect(all, isA()); + expect(all.containsKey('bulk_a'), isTrue, + reason: 'bulk_a doit apparaître dans userAttributes()'); + expect(all['bulk_a'], equals('hello')); + + Purchasely.clearUserAttributes(); + await Future.delayed(const Duration(milliseconds: 300)); + + final afterClear = await Purchasely.userAttribute('bulk_a'); + expect(afterClear, isNull, + reason: 'clearUserAttributes doit supprimer tous les attributs'); + + Purchasely.clearBuiltInAttributes(); + debugPrint('T15 → userAttributes=${all.length} entrées, ' + 'clearUserAttributes ✓, clearBuiltInAttributes no-throw ✓'); + }); + }); + }); + + // T16 — Increment / decrement + group('T16 — User attributes: increment / decrement', () { + testWidgets( + 'incrementUserAttribute / decrementUserAttribute modifient le compteur', + (tester) async { + await tester.runAsync(() async { + Purchasely.clearUserAttribute('e2e_counter'); + await Future.delayed(const Duration(milliseconds: 300)); + + await Purchasely.incrementUserAttribute('e2e_counter', value: 7); + await Future.delayed(const Duration(milliseconds: 300)); + final v1 = await Purchasely.userAttribute('e2e_counter'); + expect(v1, isNotNull); + + await Purchasely.incrementUserAttribute('e2e_counter', value: 3); + await Future.delayed(const Duration(milliseconds: 300)); + final v2 = await Purchasely.userAttribute('e2e_counter'); + expect(v2, isNotNull); + if (v1 is num && v2 is num) { + expect((v2).toDouble(), greaterThan((v1).toDouble()), + reason: 'increment doit augmenter la valeur'); + } + + await Purchasely.decrementUserAttribute('e2e_counter', value: 4); + await Future.delayed(const Duration(milliseconds: 300)); + final v3 = await Purchasely.userAttribute('e2e_counter'); + expect(v3, isNotNull); + if (v2 is num && v3 is num) { + expect((v3).toDouble(), lessThan((v2).toDouble()), + reason: 'decrement doit diminuer la valeur'); + } + + Purchasely.clearUserAttribute('e2e_counter'); + debugPrint('T16 → counter: v1=$v1 → +3 → v2=$v2 → -4 → v3=$v3 ✓'); + }); + }); + }); + + // T17 — Catalogue: productWithIdentifier / planWithIdentifier / isEligibleForIntroOffer + group( + 'T17 — Catalogue: productWithIdentifier / planWithIdentifier / isEligibleForIntroOffer', + () { + testWidgets('lookup par vendorId + eligibility check', (tester) async { + await tester.runAsync(() async { + final products = await Purchasely.allProducts(); + expect(products, isNotEmpty, + reason: + 'Au moins un produit est nécessaire pour tester le catalogue'); + + final product = products.first; + final fetched = + await Purchasely.productWithIdentifier(product.vendorId); + expect(fetched.vendorId, equals(product.vendorId)); + expect(fetched.name, isNotEmpty); + debugPrint('T17 → productWithIdentifier=${fetched.vendorId}'); + + final plan = product.plans.isNotEmpty ? product.plans.first : null; + final planId = plan?.vendorId; + if (planId != null) { + final fetchedPlan = await Purchasely.planWithIdentifier(planId); + expect(fetchedPlan, isNotNull); + expect(fetchedPlan!.vendorId, equals(planId)); + debugPrint('T17 → planWithIdentifier=${fetchedPlan.vendorId}'); + + final isEligible = await Purchasely.isEligibleForIntroOffer(planId); + expect(isEligible, isA()); + debugPrint('T17 → isEligibleForIntroOffer=$isEligible'); + } + }); + }); + }); + + // T18 — Dynamic offerings: set / get / remove / clear + group('T18 — Dynamic offerings: CRUD', () { + testWidgets( + 'setDynamicOffering → getDynamicOfferings → removeDynamicOffering → clearDynamicOfferings', + (tester) async { + await tester.runAsync(() async { + // Obtenir un planVendorId valide depuis le backend + final presentation = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + final planVendorId = presentation.plans.isNotEmpty + ? presentation.plans.first.planVendorId + : null; + expect(planVendorId, isNotNull, + reason: 'Un plan est nécessaire pour tester setDynamicOffering'); + + final ok = await Purchasely.setDynamicOffering( + PLYDynamicOffering('e2e_ref', planVendorId!, null), + ); + expect(ok, isA()); + + await Future.delayed(const Duration(milliseconds: 300)); + final offerings = await Purchasely.getDynamicOfferings(); + expect(offerings, isA>()); + + Purchasely.removeDynamicOffering('e2e_ref'); + await Future.delayed(const Duration(milliseconds: 300)); + Purchasely.clearDynamicOfferings(); + + debugPrint('T18 → setDynamicOffering=$ok ' + 'offerings=${offerings.length} ' + 'remove+clear ✓'); + }); + }); + }); + + // T19 — Builder screen(id) + variantes de transition (modal, popin) + group('T19 — Builder screen(id) + transitions: modal / popin', () { + testWidgets('PLYPresentationBuilder.screen(id) fonctionne + modal + popin', + (tester) async { + await tester.runAsync(() async { + // Obtenir screenId via placement + final byPlacement = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + final screenId = byPlacement.screenId; + expect(screenId, isNotNull); + + // Variante screen(id) → modal + final byScreen = + await PLYPresentationBuilder.screen(screenId!).build().preload(); + expect(byScreen.screenId, isNotNull); + + final f1 = byScreen.display(const PLYTransition.modal()); + await Future.delayed(const Duration(seconds: 2)); + await byScreen.close(); + final outcome1 = await f1.timeout(const Duration(seconds: 10)); + expect(outcome1.presentation?.screenId, isNotNull); + debugPrint('T19 → screen($screenId) modal → ${outcome1.closeReason}'); + + // Variante popin + final byScreen2 = + await PLYPresentationBuilder.screen(screenId).build().preload(); + final f2 = byScreen2.display(const PLYTransition.popin( + width: PLYTransitionDimension.pixel(320), + height: PLYTransitionDimension.percentage(0.6), + )); + await Future.delayed(const Duration(seconds: 2)); + await byScreen2.close(); + final outcome2 = await f2.timeout(const Duration(seconds: 10)); + expect(outcome2.presentation?.screenId, isNotNull); + debugPrint('T19 → popin → ${outcome2.closeReason}'); + }); + }); + }); + + // T20 — Config setters: smoke test (allowDeeplink, allowCampaigns, setLanguage, + // setThemeMode, setLogLevel, setDebugMode, revokeDataProcessingConsent) + // + // NOTE: handleDeeplink n'est volontairement PAS testé ici. Sur iOS, + // Purchasely.handleDeeplink(url) résout l'URL de façon synchrone sur le main + // thread (round-trip réseau pour une URL non-Purchasely), ce qui bloque le + // handshake de fin de test et fait hanguer la suite. Le vrai chemin deeplink + // (URL ply://) est couvert par default_dismiss_handler_ios_test.dart. + group('T20 — Config setters: smoke test', () { + testWidgets( + 'allowDeeplink / allowCampaigns / setLanguage / setThemeMode / setLogLevel / ' + 'setDebugMode / revokeDataProcessingConsent ne throw pas', + (tester) async { + await tester.runAsync(() async { + await Purchasely.allowDeeplink(true); + await Purchasely.allowDeeplink(false); + await Purchasely.allowCampaigns(true); + await Purchasely.allowCampaigns(false); + await Purchasely.setLanguage('en'); + await Purchasely.setThemeMode(PLYThemeMode.system); + await Purchasely.setLogLevel(PLYLogLevel.debug); + await Purchasely.setDebugMode(false); + Purchasely.revokeDataProcessingConsent( + [PLYDataProcessingPurpose.analytics]); + + // Aucune des opérations ci-dessus ne doit lever. + expect(true, isTrue); + debugPrint('T20 → all config setters no-throw ✓'); + }); + }); + }); +} diff --git a/purchasely/example/integration_test/default_dismiss_handler_ios_test.dart b/purchasely/example/integration_test/default_dismiss_handler_ios_test.dart new file mode 100644 index 00000000..6a04b6d6 --- /dev/null +++ b/purchasely/example/integration_test/default_dismiss_handler_ios_test.dart @@ -0,0 +1,84 @@ +// E2E: the global default dismiss handler receives the typed outcome for a +// presentation the SDK opens itself (here via a deeplink), and the close-button +// dismissal maps to PLYCloseReason.button. +// +// Mirror of default_dismiss_handler_test.dart for iOS. Uses PLYStore.apple. +// A concurrent host-side driver (tools/close_paywall_ios.sh) uses idb to tap +// the paywall's close button (accessibility ID: ply_action_close) once it +// renders — equivalent to pressing system BACK on Android. +// +// Run together with the driver: +// (bash .../close_paywall_ios.sh &) ; \ +// flutter test integration_test/default_dismiss_handler_ios_test.dart -d + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + bool configured = false; + try { + configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .storekitVersion(PLYStorekitVersion.storeKit2) + .start() + .timeout(const Duration(seconds: 120), + onTimeout: () => + throw StateError('Purchasely.start() timed out after 120s')); + } catch (e) { + debugPrint('SETUP → start() error: $e'); + rethrow; + } + debugPrint('SETUP → configured=$configured'); + expect(configured, isTrue); + }); + + testWidgets( + 'default dismiss handler receives outcome for an SDK-opened presentation', + (tester) async { + await tester.runAsync(() async { + PLYPresentationOutcome? globalOutcome; + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + globalOutcome = outcome; + }); + + // The SDK opens the presentation itself (deeplink) — its dismissal is + // routed to the default handler, not to any per-request onDismissed. + final handled = await Purchasely.handleDeeplink( + 'ply://ply/placements/$kPlacementAudiences'); + expect(handled, isTrue, reason: 'deeplink route should be handled'); + + // The concurrent driver taps ply_action_close once the paywall renders. + // Poll for the default handler to receive the dismissal outcome. + final sw = Stopwatch()..start(); + while ( + globalOutcome == null && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(globalOutcome, isNotNull, + reason: 'default dismiss handler should fire after dismissal'); + expect(globalOutcome!.error, isNull); + // Tapping the SDK close button maps to PLYCloseReason.button on iOS. + // programmatic is also accepted (e.g. if the SDK attributes it differently). + expect( + globalOutcome!.closeReason, + anyOf(PLYCloseReason.button, PLYCloseReason.programmatic, + PLYCloseReason.backSystem), + ); + debugPrint('default dismiss handler → ' + 'closeReason=${globalOutcome!.closeReason} ' + 'presentation=${globalOutcome!.presentation?.screenId}'); + }); + }); +} diff --git a/purchasely/example/integration_test/default_dismiss_handler_test.dart b/purchasely/example/integration_test/default_dismiss_handler_test.dart new file mode 100644 index 00000000..4e0f5af6 --- /dev/null +++ b/purchasely/example/integration_test/default_dismiss_handler_test.dart @@ -0,0 +1,71 @@ +// E2E: the global default dismiss handler receives the typed outcome for a +// presentation the SDK opens itself (here via a deeplink), and the system-back +// dismissal maps to PLYCloseReason.backSystem. +// +// A concurrent host-side driver (scripts: press_back.sh) waits for the paywall +// to render, then presses the system BACK button. +// +// Run together with the driver: +// (bash .../press_back.sh &) ; \ +// flutter test integration_test/default_dismiss_handler_test.dart -d emulator-5554 + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .stores([PLYStore.google]).start(); + expect(configured, isTrue); + }); + + testWidgets( + 'default dismiss handler receives outcome for an SDK-opened presentation', + (tester) async { + await tester.runAsync(() async { + PLYPresentationOutcome? globalOutcome; + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + globalOutcome = outcome; + }); + + // The SDK opens the presentation itself (deeplink) — its dismissal is + // routed to the default handler, not to any per-request onDismissed. + final handled = await Purchasely.handleDeeplink( + 'ply://ply/placements/$kPlacementAudiences'); + expect(handled, isTrue, reason: 'deeplink route should be handled'); + + // The concurrent driver presses BACK once the paywall renders. Poll for + // the default handler to receive the dismissal outcome. + final sw = Stopwatch()..start(); + while ( + globalOutcome == null && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(globalOutcome, isNotNull, + reason: 'default dismiss handler should fire after dismissal'); + expect(globalOutcome!.error, isNull); + // System-back dismissal maps to backSystem; allow programmatic/button in + // case the SDK attributes the dismissal differently. interactiveDismiss + // no longer exists in the reduced v6 enum. + expect( + globalOutcome!.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.programmatic, + PLYCloseReason.button), + ); + debugPrint('default dismiss handler → ' + 'closeReason=${globalOutcome!.closeReason} ' + 'presentation=${globalOutcome!.presentation?.screenId}'); + }); + }); +} diff --git a/purchasely/example/integration_test/default_dismiss_via_display_ios_test.dart b/purchasely/example/integration_test/default_dismiss_via_display_ios_test.dart new file mode 100644 index 00000000..786c5727 --- /dev/null +++ b/purchasely/example/integration_test/default_dismiss_via_display_ios_test.dart @@ -0,0 +1,91 @@ +// E2E: a host-initiated display() that is fire-and-forget (NOT awaited, and with +// NO per-presentation onDismissed callback) routes its dismissal to the global +// default dismiss handler — the new fallback in `_handleOnDismissed`. +// +// iOS mirror of default_dismiss_via_display_test.dart. Uses PLYStore.apple. +// A concurrent host-side driver (tools/close_paywall_ios.sh) uses idb to tap +// the paywall's close button (accessibility ID: ply_action_close) once it +// renders — equivalent to pressing system BACK on Android. +// +// Run together with the driver: +// (bash .../close_paywall_ios.sh &) ; \ +// flutter test integration_test/default_dismiss_via_display_ios_test.dart -d + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + bool configured = false; + try { + configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .storekitVersion(PLYStorekitVersion.storeKit2) + .start() + .timeout(const Duration(seconds: 120), + onTimeout: () => + throw StateError('Purchasely.start() timed out after 120s')); + } catch (e) { + debugPrint('SETUP → start() error: $e'); + rethrow; + } + debugPrint('SETUP → configured=$configured'); + expect(configured, isTrue); + }); + + testWidgets( + 'fire-and-forget display() routes dismissal to the default dismiss handler', + (tester) async { + await tester.runAsync(() async { + PLYPresentationOutcome? globalOutcome; + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + globalOutcome = outcome; + }); + + // The host opens the presentation itself via display(). Crucially: + // * no onDismissed is set on the builder/presentation, and + // * the display() future is intentionally not awaited (fire-and-forget), + // so the dismissal isn't handled locally and must reach the default handler. + final presentation = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + // Fire-and-forget: intentionally not awaited. + // ignore: unawaited_futures + presentation.display(); + + // The concurrent driver taps ply_action_close once the paywall renders. + // Poll for the default handler to receive the dismissal outcome. + final sw = Stopwatch()..start(); + while ( + globalOutcome == null && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(globalOutcome, isNotNull, + reason: 'default dismiss handler should catch the unhandled ' + 'fire-and-forget display() dismissal'); + expect(globalOutcome!.error, isNull); + // Tapping the SDK close button maps to PLYCloseReason.button on iOS. + // programmatic/backSystem also accepted (different attribution paths). + expect( + globalOutcome!.closeReason, + anyOf(PLYCloseReason.button, PLYCloseReason.programmatic, + PLYCloseReason.backSystem), + ); + debugPrint('default dismiss handler (via display) → ' + 'closeReason=${globalOutcome!.closeReason} ' + 'presentation=${globalOutcome!.presentation?.screenId}'); + }); + }); +} diff --git a/purchasely/example/integration_test/default_dismiss_via_display_test.dart b/purchasely/example/integration_test/default_dismiss_via_display_test.dart new file mode 100644 index 00000000..c8a006ae --- /dev/null +++ b/purchasely/example/integration_test/default_dismiss_via_display_test.dart @@ -0,0 +1,83 @@ +// E2E: a host-initiated display() that is fire-and-forget (NOT awaited, and with +// NO per-presentation onDismissed callback) routes its dismissal to the global +// default dismiss handler — the new fallback in `_handleOnDismissed`. +// +// This is the Dart-opened counterpart of default_dismiss_handler_test.dart +// (which opens the screen via the SDK itself, through a deeplink). Here the app +// owns the display() call, so the dismissal flows through the per-request +// onDismissed event; because the host set no local handler, it must fall back to +// setDefaultPresentationDismissHandler instead of being dropped. +// +// A concurrent host-side driver (scripts: press_back.sh) waits for the paywall +// to render, then presses the system BACK button. +// +// Run together with the driver: +// (bash .../press_back.sh &) ; \ +// flutter test integration_test/default_dismiss_via_display_test.dart -d emulator-5554 + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .stores([PLYStore.google]).start(); + expect(configured, isTrue); + }); + + testWidgets( + 'fire-and-forget display() routes dismissal to the default dismiss handler', + (tester) async { + await tester.runAsync(() async { + PLYPresentationOutcome? globalOutcome; + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + globalOutcome = outcome; + }); + + // The host opens the presentation itself via display(). Crucially: + // * no onDismissed is set on the builder/presentation, and + // * the display() future is intentionally not awaited (fire-and-forget), + // so the dismissal isn't handled locally and must reach the default handler. + final presentation = + await PLYPresentationBuilder.placement(kPlacementAudiences) + .build() + .preload(); + // Fire-and-forget: intentionally not awaited. + // ignore: unawaited_futures + presentation.display(); + + // The concurrent driver presses BACK once the paywall renders. Poll for + // the default handler to receive the dismissal outcome. + final sw = Stopwatch()..start(); + while ( + globalOutcome == null && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(globalOutcome, isNotNull, + reason: 'default dismiss handler should catch the unhandled ' + 'fire-and-forget display() dismissal'); + expect(globalOutcome!.error, isNull); + // System-back dismissal maps to backSystem; allow programmatic/button in + // case the SDK attributes the dismissal differently. + expect( + globalOutcome!.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.programmatic, + PLYCloseReason.button), + ); + debugPrint('default dismiss handler (via display) → ' + 'closeReason=${globalOutcome!.closeReason} ' + 'presentation=${globalOutcome!.presentation?.screenId}'); + }); + }); +} diff --git a/purchasely/example/integration_test/inline_paywall_test.dart b/purchasely/example/integration_test/inline_paywall_test.dart new file mode 100644 index 00000000..2240f84b --- /dev/null +++ b/purchasely/example/integration_test/inline_paywall_test.dart @@ -0,0 +1,86 @@ +// E2E (Android + iOS): inline paywall rendered via PLYPresentationView. +// +// Unlike the modal display() suites, the inline path MOUNTS the widget so the +// native platform view embeds inside the Flutter surface (hybrid composition on +// Android, UiKitView on iOS). This test verifies the inline view renders end to +// end against the real backend: it preloads, mounts, and fires onPresented with +// a valid screenId. +// +// NOTE on the close (✕) flow: tapping the native close button cannot be driven +// from inside `integration_test` — the harness does not deliver adb/idb taps to +// the embedded platform view. The close → onCloseRequested → pop flow is +// therefore verified in the REAL app (flutter run) on both platforms; see +// INLINE_PAYWALL_CLOSE.md. This test guards the render path, which IS testable. +// +// Parametric via --dart-define so it can target any key/placement: +// flutter test integration_test/inline_paywall_test.dart -d \ +// --dart-define=PLY_KEY=... --dart-define=PLY_PLACEMENT=... + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/native_view_widget.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = String.fromEnvironment('PLY_KEY', + defaultValue: 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d'); +const String kPlacement = + String.fromEnvironment('PLY_PLACEMENT', defaultValue: 'promo_offers'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .stores([PLYStore.google]).start(); + expect(configured, isTrue); + }); + + testWidgets('inline PLYPresentationView preloads, mounts and presents', + (tester) async { + await tester.runAsync(() async { + final presented = Completer(); + + final request = PLYPresentationBuilder.placement(kPlacement) + .onPresented((presentation, error) { + if (presentation != null && !presented.isCompleted) { + presented.complete(presentation); + } + }).build(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + const SizedBox(height: 60, child: Center(child: Text('ABOVE'))), + Expanded(child: PLYPresentationView(request: request)), + const SizedBox(height: 60, child: Center(child: Text('BELOW'))), + ], + ), + ), + ), + ); + + // Let the platform view mount + preload + render. + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 250)); + if (presented.isCompleted) break; + } + + final presentation = await presented.future.timeout( + const Duration(seconds: 30), + onTimeout: () => throw StateError( + 'inline onPresented never fired — the embedded view did not render'), + ); + + expect(presentation.screenId, isNotNull); + debugPrint('inline rendered screenId=${presentation.screenId}'); + }); + }); +} diff --git a/purchasely/example/integration_test/interceptor_trigger_ios_test.dart b/purchasely/example/integration_test/interceptor_trigger_ios_test.dart new file mode 100644 index 00000000..9fb28e6a --- /dev/null +++ b/purchasely/example/integration_test/interceptor_trigger_ios_test.dart @@ -0,0 +1,103 @@ +// E2E: action interceptor is actually TRIGGERED by a real tap on the native +// paywall, and the typed payload is delivered to Dart. +// +// Mirror of interceptor_trigger_test.dart for iOS. Uses PLYStore.apple and +// a concurrent host-side driver (tools/tap_purchase_ios.sh) that uses idb to +// tap the purchase button by its accessibility identifier +// (ply_action_purchase_). +// +// Run together with the driver: +// (bash .../tap_purchase_ios.sh &) ; \ +// flutter test integration_test/interceptor_trigger_ios_test.dart -d + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + bool configured = false; + try { + configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .storekitVersion(PLYStorekitVersion.storeKit2) + .start() + .timeout(const Duration(seconds: 120), + onTimeout: () => + throw StateError('Purchasely.start() timed out after 120s')); + } catch (e) { + debugPrint('SETUP → start() error: $e'); + rethrow; + } + debugPrint('SETUP → configured=$configured'); + expect(configured, isTrue); + }); + + testWidgets( + 'purchase action interceptor fires with a typed PLYPurchasePayload on tap', + (tester) async { + await tester.runAsync(() async { + PLYInterceptorInfo? capturedInfo; + PLYActionPayload? capturedPayload; + var presented = false; + + // SUCCESS for purchase: skip the SDK default but continue the chain. + // SUCCESS for close_all keeps the paywall open so the driver has time to + // detect and assert (mirrors native iOS ACT-01 / Android ACT-01). + await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) async { + capturedInfo = info; + capturedPayload = payload; + return PLYInterceptResult.success; + }, + ); + await Purchasely.interceptAction( + PLYPresentationActionKind.closeAll, + (info, payload) async => PLYInterceptResult.success, + ); + + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((p, e) => presented = true) + .build(); + // ignore: unawaited_futures + request.display(const PLYTransition.fullScreen()); + + // Wait for the paywall to present. + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 20)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'paywall should present'); + + // The concurrent driver taps the purchase button. Poll for interceptor. + final fireSw = Stopwatch()..start(); + while (capturedPayload == null && + fireSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(capturedPayload, isA(), + reason: 'purchase interceptor should fire on the native tap'); + expect(capturedPayload!.kind, PLYPresentationActionKind.purchase); + final purchase = capturedPayload as PLYPurchasePayload; + expect(purchase.plan, isA()); + expect(purchase.plan.vendorId, isNotNull); + expect(capturedInfo, isNotNull); + debugPrint('interceptor fired → kind=${capturedPayload!.kind} ' + 'plan.vendorId=${purchase.plan.vendorId} ' + 'plan.productId=${purchase.plan.productId} ' + 'contentId=${capturedInfo!.contentId}'); + + await Purchasely.removeAllActionInterceptors(); + }); + }); +} diff --git a/purchasely/example/integration_test/interceptor_trigger_test.dart b/purchasely/example/integration_test/interceptor_trigger_test.dart new file mode 100644 index 00000000..396c1764 --- /dev/null +++ b/purchasely/example/integration_test/interceptor_trigger_test.dart @@ -0,0 +1,92 @@ +// E2E: action interceptor is actually TRIGGERED by a real tap on the native +// paywall, and the typed payload is delivered to Dart. +// +// This test displays the `integration_test_audiences` placement (which exposes a +// purchase button with content-desc `action:purchase,plan:monthly; action:close_all`, +// mirroring the native Android PaywallActionInterceptorTests) and then polls for +// the interceptor to fire. A concurrent host-side driver +// (scripts: tap_purchase.sh) taps the purchase button via uiautomator. +// +// Run together with the driver: +// (bash .../tap_purchase.sh &) ; \ +// flutter test integration_test/interceptor_trigger_test.dart -d emulator-5554 + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .stores([PLYStore.google]).start(); + expect(configured, isTrue); + }); + + testWidgets( + 'purchase action interceptor fires with a typed PLYPurchasePayload on tap', + (tester) async { + await tester.runAsync(() async { + PLYInterceptorInfo? capturedInfo; + PLYActionPayload? capturedPayload; + var presented = false; + + // SUCCESS for purchase: skip the SDK default but continue the chain. The + // chain then fires close_all, which we also intercept with SUCCESS so the + // paywall stays open (mirrors native Android ACT-01). + await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) async { + capturedInfo = info; + capturedPayload = payload; + return PLYInterceptResult.success; + }, + ); + await Purchasely.interceptAction( + PLYPresentationActionKind.closeAll, + (info, payload) async => PLYInterceptResult.success, + ); + + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((p, e) => presented = true) + .build(); + // ignore: unawaited_futures + request.display(const PLYTransition.fullScreen()); + + // Wait for the paywall to present. + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 20)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'paywall should present'); + + // The concurrent driver taps `action:purchase`. Poll for the interceptor. + final fireSw = Stopwatch()..start(); + while (capturedPayload == null && + fireSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(capturedPayload, isA(), + reason: 'purchase interceptor should fire on the native tap'); + expect(capturedPayload!.kind, PLYPresentationActionKind.purchase); + final purchase = capturedPayload as PLYPurchasePayload; + expect(purchase.plan, isA()); + expect(purchase.plan.vendorId, isNotNull); + expect(capturedInfo, isNotNull); + debugPrint('interceptor fired → kind=${capturedPayload!.kind} ' + 'plan.vendorId=${purchase.plan.vendorId} ' + 'plan.productId=${purchase.plan.productId} ' + 'contentId=${capturedInfo!.contentId}'); + + await Purchasely.removeAllActionInterceptors(); + }); + }); +} diff --git a/purchasely/example/integration_test/local_dismiss_handler_ios_test.dart b/purchasely/example/integration_test/local_dismiss_handler_ios_test.dart new file mode 100644 index 00000000..899aef10 --- /dev/null +++ b/purchasely/example/integration_test/local_dismiss_handler_ios_test.dart @@ -0,0 +1,96 @@ +// E2E: when BOTH a global default dismiss handler AND a per-presentation local +// handler are set, the LOCAL path wins. Here the host awaits display() and also +// sets onDismissed; the dismissal must reach the awaiting caller + the local +// onDismissed, while the default handler stays silent. +// +// iOS mirror of local_dismiss_handler_test.dart. Uses PLYStore.apple. +// A concurrent host-side driver (tools/close_paywall_ios.sh) uses idb to tap +// the paywall's close button (accessibility ID: ply_action_close) once it +// renders — equivalent to pressing system BACK on Android. +// +// Run together with the driver: +// (bash .../close_paywall_ios.sh &) ; \ +// flutter test integration_test/local_dismiss_handler_ios_test.dart -d + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + bool configured = false; + try { + configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .storekitVersion(PLYStorekitVersion.storeKit2) + .start() + .timeout(const Duration(seconds: 120), + onTimeout: () => + throw StateError('Purchasely.start() timed out after 120s')); + } catch (e) { + debugPrint('SETUP → start() error: $e'); + rethrow; + } + debugPrint('SETUP → configured=$configured'); + expect(configured, isTrue); + }); + + testWidgets( + 'awaited display() with a local onDismissed wins over the default handler', + (tester) async { + await tester.runAsync(() async { + PLYPresentationOutcome? defaultOutcome; + PLYPresentationOutcome? localOutcome; + + // A default handler is registered globally… + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + defaultOutcome = outcome; + }); + + // …but this presentation also declares a local onDismissed AND the caller + // awaits display(): both local channels must receive the outcome and the + // default handler must NOT fire. + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onDismissed((outcome) => localOutcome = outcome) + .build(); + await request.preload(); + + // The concurrent driver taps ply_action_close once the paywall renders, + // which resolves the awaited display() future. + final outcome = await request.display().timeout( + const Duration(seconds: 50), + onTimeout: () => throw StateError( + 'display() did not resolve — the driver may not have dismissed ' + 'the paywall'), + ); + + // The awaiting caller received the outcome (local consumption). + expect(outcome.error, isNull); + expect( + outcome.closeReason, + anyOf(PLYCloseReason.button, PLYCloseReason.programmatic, + PLYCloseReason.backSystem), + ); + // The local onDismissed also received it… + expect(localOutcome, isNotNull, + reason: 'local onDismissed should receive the outcome'); + // …and the default handler must stay silent. + expect(defaultOutcome, isNull, + reason: 'default handler must NOT fire when a local handler is set'); + + debugPrint('local dismiss handler → ' + 'closeReason=${outcome.closeReason} ' + 'presentation=${outcome.presentation?.screenId} ' + 'defaultFired=${defaultOutcome != null}'); + }); + }); +} diff --git a/purchasely/example/integration_test/local_dismiss_handler_test.dart b/purchasely/example/integration_test/local_dismiss_handler_test.dart new file mode 100644 index 00000000..7b12c0b7 --- /dev/null +++ b/purchasely/example/integration_test/local_dismiss_handler_test.dart @@ -0,0 +1,86 @@ +// E2E: when BOTH a global default dismiss handler AND a per-presentation local +// handler are set, the LOCAL path wins. Here the host awaits display() and also +// sets onDismissed; the dismissal must reach the awaiting caller + the local +// onDismissed, while the default handler stays silent. +// +// Counterpart of default_dismiss_via_display_test.dart (which sets NO local +// handler, so the dismissal falls back to the default handler). Together they +// pin both branches of the dismiss-routing fallback in `_handleOnDismissed`. +// +// A concurrent host-side driver (scripts: press_back.sh) waits for the paywall +// to render, then presses the system BACK button. +// +// Run together with the driver: +// (bash .../press_back.sh &) ; \ +// flutter test integration_test/local_dismiss_handler_test.dart -d emulator-5554 + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87'; +const String kPlacementAudiences = 'integration_test_audiences'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final configured = await Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .stores([PLYStore.google]).start(); + expect(configured, isTrue); + }); + + testWidgets( + 'awaited display() with a local onDismissed wins over the default handler', + (tester) async { + await tester.runAsync(() async { + PLYPresentationOutcome? defaultOutcome; + PLYPresentationOutcome? localOutcome; + + // A default handler is registered globally… + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + defaultOutcome = outcome; + }); + + // …but this presentation also declares a local onDismissed AND the caller + // awaits display(): both local channels must receive the outcome and the + // default handler must NOT fire. + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onDismissed((outcome) => localOutcome = outcome) + .build(); + await request.preload(); + + // The concurrent driver presses BACK once the paywall renders, which + // resolves the awaited display() future. + final outcome = await request.display().timeout( + const Duration(seconds: 50), + onTimeout: () => throw StateError( + 'display() did not resolve — the driver may not have dismissed ' + 'the paywall'), + ); + + // The awaiting caller received the outcome (local consumption). + expect(outcome.error, isNull); + expect( + outcome.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.programmatic, + PLYCloseReason.button), + ); + // The local onDismissed also received it… + expect(localOutcome, isNotNull, + reason: 'local onDismissed should receive the outcome'); + // …and the default handler must stay silent. + expect(defaultOutcome, isNull, + reason: 'default handler must NOT fire when a local handler is set'); + + debugPrint('local dismiss handler → ' + 'closeReason=${outcome.closeReason} ' + 'presentation=${outcome.presentation?.screenId} ' + 'defaultFired=${defaultOutcome != null}'); + }); + }); +} diff --git a/purchasely/example/integration_test/tools/ci_run_e2e.sh b/purchasely/example/integration_test/tools/ci_run_e2e.sh new file mode 100755 index 00000000..6085d5ee --- /dev/null +++ b/purchasely/example/integration_test/tools/ci_run_e2e.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# CI entrypoint for the Android E2E suite, invoked by the emulator-runner once the +# emulator has booted (see .github/workflows/e2e-android.yml). Tees per-suite logs +# to integration_test/ci-logs/ for artifact upload. +# +# Gating model: +# * bridge (T1–T20, no native interaction) = HARD gate. Deterministic once the +# SDK starts; retried for robustness. +# * interceptor / dismiss = BEST-EFFORT (non-blocking). They drive a real +# uiautomator tap / system BACK on the custom-rendered paywall, which is +# inherently flaky on the CI emulator. Run for signal; a failure emits a +# warning but does NOT fail the job. +set -uo pipefail + +DEV="${1:-emulator-5554}" +HERE="$(cd "$(dirname "$0")" && pwd)" +EXAMPLE_DIR="$(cd "$HERE/../.." && pwd)" # → purchasely/example +cd "$EXAMPLE_DIR" + +LOGS="integration_test/ci-logs" +mkdir -p "$LOGS" + +adb -s "$DEV" wait-for-device +flutter pub get + +# Run a suite up to 3×; pass if any attempt passes. $3 = optional driver script. +run_suite() { + local label="$1" testfile="$2" driver="$3" logbase="$4" + local attempts=3 dpid="" + for a in $(seq 1 "$attempts"); do + echo "=== $label (attempt $a/$attempts) ===" + dpid="" + if [ -n "$driver" ]; then + bash "$HERE/$driver" "$DEV" > "$LOGS/${logbase}_driver_$a.log" 2>&1 & + dpid=$! + fi + if flutter test "$testfile" -d "$DEV" 2>&1 | tee "$LOGS/${logbase}_$a.log"; then + cp "$LOGS/${logbase}_$a.log" "$LOGS/${logbase}.log" 2>/dev/null || true + [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true + echo "=== $label passed on attempt $a ===" + return 0 + fi + [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true + echo "=== $label failed attempt $a ===" + adb -s "$DEV" shell am force-stop com.purchasely.demo 2>/dev/null || true + sleep 3 + done + cp "$LOGS/${logbase}_${attempts}.log" "$LOGS/${logbase}.log" 2>/dev/null || true + return 1 +} + +fail=0 + +echo "=== Suite 1/3: Dart↔Android bridge (T1–T20) — HARD gate ===" +run_suite "bridge" integration_test/dart_android_bridge_test.dart "" bridge || fail=1 + +echo "=== Suite 2/3: interceptor trigger (uiautomator tap) — best-effort ===" +run_suite "interceptor" integration_test/interceptor_trigger_test.dart \ + tap_purchase.sh interceptor \ + || echo "::warning::E2E Android interceptor suite failed after retries (non-blocking)" + +echo "=== Suite 3/5: default dismiss handler via deeplink (system BACK) — best-effort ===" +run_suite "dismiss" integration_test/default_dismiss_handler_test.dart \ + press_back.sh dismiss \ + || echo "::warning::E2E Android dismiss suite failed after retries (non-blocking)" + +echo "=== Suite 4/5: default dismiss handler via fire-and-forget display() (system BACK) — best-effort ===" +run_suite "dismiss_via_display" integration_test/default_dismiss_via_display_test.dart \ + press_back.sh dismiss_via_display \ + || echo "::warning::E2E Android dismiss-via-display suite failed after retries (non-blocking)" + +echo "=== Suite 5/5: local dismiss handler wins over default (system BACK) — best-effort ===" +run_suite "local_dismiss" integration_test/local_dismiss_handler_test.dart \ + press_back.sh local_dismiss \ + || echo "::warning::E2E Android local-dismiss suite failed after retries (non-blocking)" + +echo "=== E2E Android finished (gating fail=$fail) ===" +exit $fail diff --git a/purchasely/example/integration_test/tools/ci_run_e2e_ios.sh b/purchasely/example/integration_test/tools/ci_run_e2e_ios.sh new file mode 100755 index 00000000..8406bba0 --- /dev/null +++ b/purchasely/example/integration_test/tools/ci_run_e2e_ios.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# CI entrypoint for the iOS E2E suite. Runs the three iOS test files on the +# booted simulator passed as $1. Tees logs to integration_test/ci-logs/. +# +# Usage: bash ci_run_e2e_ios.sh +# +# Gating model: +# * bridge (T1–T20, no native interaction) = HARD gate. Deterministic once the +# SDK starts; retried because Purchasely.start() occasionally times out on +# the CI simulator (slow backend round-trip). +# * interceptor / dismiss = BEST-EFFORT (non-blocking). They drive a real +# native tap/swipe on the custom-rendered paywall via idb, which is +# inherently flaky on the CI simulator. Run for signal; a failure emits a +# warning but does NOT fail the job. +set -uo pipefail + +DEV="${1:?usage: $0 }" +HERE="$(cd "$(dirname "$0")" && pwd)" +EXAMPLE_DIR="$(cd "$HERE/../.." && pwd)" # → purchasely/example +cd "$EXAMPLE_DIR" + +LOGS="integration_test/ci-logs" +mkdir -p "$LOGS" + +flutter pub get + +# Run a suite up to 3×; pass if any attempt passes. $3 = optional driver script. +run_suite() { + local label="$1" testfile="$2" driver="$3" logbase="$4" + local attempts=3 dpid="" + for a in $(seq 1 "$attempts"); do + echo "=== $label (attempt $a/$attempts) ===" + dpid="" + if [ -n "$driver" ]; then + bash "$HERE/$driver" "$DEV" > "$LOGS/${logbase}_driver_$a.log" 2>&1 & + dpid=$! + fi + if flutter test "$testfile" -d "$DEV" --reporter expanded 2>&1 | tee "$LOGS/${logbase}_$a.log"; then + cp "$LOGS/${logbase}_$a.log" "$LOGS/${logbase}.log" 2>/dev/null || true + [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true + echo "=== $label passed on attempt $a ===" + return 0 + fi + [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true + echo "=== $label failed attempt $a ===" + xcrun simctl terminate "$DEV" com.purchasely.demo 2>/dev/null || true + sleep 3 + done + cp "$LOGS/${logbase}_${attempts}.log" "$LOGS/${logbase}.log" 2>/dev/null || true + return 1 +} + +fail=0 + +echo "=== Suite 1/3: Dart↔iOS bridge (T1–T20) — HARD gate ===" +run_suite "bridge-ios" integration_test/dart_ios_bridge_test.dart "" bridge || fail=1 + +echo "=== Suite 2/3: interceptor trigger (idb tap) — best-effort ===" +run_suite "interceptor-ios" integration_test/interceptor_trigger_ios_test.dart \ + tap_purchase_ios.sh interceptor_ios \ + || echo "::warning::E2E iOS interceptor suite failed after retries (non-blocking)" + +echo "=== Suite 3/5: default dismiss handler via deeplink (idb tap close) — best-effort ===" +run_suite "dismiss-ios" integration_test/default_dismiss_handler_ios_test.dart \ + close_paywall_ios.sh dismiss_ios \ + || echo "::warning::E2E iOS dismiss suite failed after retries (non-blocking)" + +echo "=== Suite 4/5: default dismiss handler via fire-and-forget display() (idb tap close) — best-effort ===" +run_suite "dismiss-via-display-ios" integration_test/default_dismiss_via_display_ios_test.dart \ + close_paywall_ios.sh dismiss_via_display_ios \ + || echo "::warning::E2E iOS dismiss-via-display suite failed after retries (non-blocking)" + +echo "=== Suite 5/5: local dismiss handler wins over default (idb tap close) — best-effort ===" +run_suite "local-dismiss-ios" integration_test/local_dismiss_handler_ios_test.dart \ + close_paywall_ios.sh local_dismiss_ios \ + || echo "::warning::E2E iOS local-dismiss suite failed after retries (non-blocking)" + +echo "=== E2E iOS finished (gating fail=$fail) ===" +exit $fail diff --git a/purchasely/example/integration_test/tools/close_paywall_ios.sh b/purchasely/example/integration_test/tools/close_paywall_ios.sh new file mode 100755 index 00000000..f7ab0937 --- /dev/null +++ b/purchasely/example/integration_test/tools/close_paywall_ios.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Host-side UI driver for default_dismiss_handler_ios_test.dart. +# +# The Purchasely iOS paywall is custom-rendered: its accessibility tree exposes +# only StaticText (no close button / no accessibility identifier). So instead of +# tapping a close affordance (as Android's press_back.sh does), we dismiss the +# SDK-opened (deeplink) presentation with a downward swipe — the gesture that +# dismisses a modally-presented sheet on iOS. +# +# Uses `idb` (pip install fb-idb) + idb-companion (brew install idb-companion). +# A wrapper sets up an asyncio event loop before idb's main(), fixing Python +# 3.12+. The AX JSON is passed to the parser via an env var (NOT stdin), since +# `python3 - < & +# flutter test integration_test/default_dismiss_handler_ios_test.dart -d +# +# Exits 0 after swiping to dismiss, 1 on timeout. +set -uo pipefail + +UDID="${1:?usage: $0 }" +# Labels that prove a Purchasely paywall is on screen (locale-independent +# marker first). Once detected, we swipe to dismiss. +PAYWALL_MARKERS="Powered by Purchasely|Restore purchase|Continue" + +run_idb() { + python3 - "$@" <<'__PYEOF__' +import asyncio, sys +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +from idb.cli.main import main +sys.exit(main()) +__PYEOF__ +} + +paywall_geometry() { + # Prints "W H" (screen size) if a paywall marker is present, else nothing. + local raw + raw=$(run_idb ui describe-all --json --udid "$UDID" 2>/dev/null) || return 1 + AXJSON="$raw" MARKERS="$PAYWALL_MARKERS" python3 <<'PY' +import os, json +markers = [m.strip().lower() for m in os.environ["MARKERS"].split("|")] +try: + data = json.loads(os.environ["AXJSON"]) +except Exception: + raise SystemExit(0) +labels = [(el.get("AXLabel") or "").strip().lower() for el in data] +if any(m in labels for m in markers): + # The application element carries the full-screen frame. + for el in data: + if el.get("type") == "Application": + f = el.get("frame", {}) + print(f"{int(round(f.get('width', 390)))} {int(round(f.get('height', 844)))}") + break + else: + print("390 844") +PY +} + +swipes=0 +for i in $(seq 1 60); do + geom=$(paywall_geometry) + if [ -n "$geom" ]; then + w=$(echo "$geom" | awk '{print $1}') + h=$(echo "$geom" | awk '{print $2}') + cx=$((w / 2)) + y_start=$((h / 5)) + y_end=$((h - 20)) + # Let the paywall settle, then swipe down to dismiss the modal sheet. A single + # swipe occasionally doesn't dismiss (gesture starts mid-content), so repeat a + # few times until the paywall is gone or we've tried enough. + sleep 1 + echo "[close_paywall_ios] paywall detected (${w}x${h}); swiping down ($cx,$y_start)->($cx,$y_end)…" + run_idb ui swipe "$cx" "$y_start" "$cx" "$y_end" --duration 0.25 --udid "$UDID" 2>&1 + swipes=$((swipes + 1)) + echo "[close_paywall_ios] swipe $swipes sent ✓" + [ "$swipes" -ge 5 ] && exit 0 + sleep 2 + else + # Paywall not present: either not up yet, or already dismissed by our swipe. + [ "$swipes" -gt 0 ] && { echo "[close_paywall_ios] paywall gone after $swipes swipe(s)"; exit 0; } + echo "[close_paywall_ios] paywall not detected yet (iter $i/60), retrying…" + sleep 1 + fi +done + +[ "$swipes" -gt 0 ] && exit 0 +echo "[close_paywall_ios] paywall not detected after 60 s" +exit 1 diff --git a/purchasely/example/integration_test/tools/press_back.sh b/purchasely/example/integration_test/tools/press_back.sh new file mode 100755 index 00000000..eea63ed9 --- /dev/null +++ b/purchasely/example/integration_test/tools/press_back.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Host-side UI driver for default_dismiss_handler_test.dart. +# +# Waits for a Purchasely paywall to render (any content-desc containing "action:") +# then presses the system BACK button to dismiss it. Run it concurrently: +# bash integration_test/tools/press_back.sh emulator-5554 & +# flutter test integration_test/default_dismiss_handler_test.dart -d emulator-5554 +# +# Verbose per-iteration logging + dump retry: `uiautomator dump` can transiently +# fail with "could not get idle state" while the paywall is still animating / +# loading (more common on the slow CI emulator), so we retry the dump and log +# every iteration's outcome to survive being killed when the test ends. +# +# Exits 0 after pressing BACK, 1 on timeout. +set -uo pipefail + +DEV="${1:-emulator-5554}" +DUMP_DEV="/sdcard/uidump_back.xml" +DUMP_LOCAL="/tmp/uidump_back_${DEV//[^a-zA-Z0-9]/_}.xml" + +dump_ui() { + # Try a few times; uiautomator needs the UI to be idle. + local out + for _ in 1 2 3; do + out=$(adb -s "$DEV" exec-out uiautomator dump "$DUMP_DEV" 2>&1) + if echo "$out" | grep -q "dumped to"; then + adb -s "$DEV" pull "$DUMP_DEV" "$DUMP_LOCAL" >/dev/null 2>&1 && return 0 + fi + sleep 1 + done + echo " dump failed: $out" + return 1 +} + +for i in $(seq 1 90); do + if dump_ui; then + if grep -q 'action:' "$DUMP_LOCAL" 2>/dev/null; then + echo "[press_back] paywall detected (iter $i), pressing BACK" + sleep 1 + adb -s "$DEV" shell input keyevent 4 + echo "[press_back] BACK pressed ✓" + exit 0 + else + n=$(grep -c '/dev/null || echo 0) + echo "[press_back] iter $i: dump ok ($n nodes), no 'action:' yet" + if [ "$i" = "5" ]; then + echo " [diag] focus: $(adb -s "$DEV" shell dumpsys window 2>/dev/null | grep -E 'mCurrentFocus|mFocusedApp' | tr -d '\r')" + echo " [diag] dump head: $(head -c 1200 "$DUMP_LOCAL" 2>/dev/null | tr -d '\n')" + fi + fi + else + echo "[press_back] iter $i: dump unavailable, retrying" + fi + sleep 1 +done +echo "[press_back] paywall not detected after polling" +exit 1 diff --git a/purchasely/example/integration_test/tools/tap_close_inline.sh b/purchasely/example/integration_test/tools/tap_close_inline.sh new file mode 100755 index 00000000..93f13e8c --- /dev/null +++ b/purchasely/example/integration_test/tools/tap_close_inline.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Host-side UI driver: taps the inline paywall's close (✕) button. Detects the +# smallest standalone close node (content-desc exactly "action:close" or +# "action:close_all"), logs it, taps it, then reports if the paywall is gone. +set -uo pipefail +DEVICE="${1:-emulator-5554}" +OUT="${2:-/tmp/inline_tap}" +ADB="adb -s $DEVICE" +mkdir -p "$OUT" + +find_close() { + python3 - "$1" <<'PY' +import re, sys +xml = sys.argv[1] if len(sys.argv) > 1 else "" +best = None +for m in re.finditer(r'content-desc="([^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml): + desc = m.group(1).strip() + x1, y1, x2, y2 = map(int, m.groups()[1:]) + if desc in ("action:close", "action:close_all"): + area = (x2 - x1) * (y2 - y1) + if best is None or area < best[0]: + best = (area, (x1 + x2) // 2, (y1 + y2) // 2, desc) +if best: + print(f"{best[1]} {best[2]} {best[3]}") +PY +} + +for i in $(seq 1 60); do + raw=$($ADB exec-out uiautomator dump /dev/tty 2>/dev/null) + if echo "$raw" | grep -q 'action:'; then + echo "$raw" | tr '>' '>\n' | grep -oE 'content-desc="[^"]*"' | sort -u > "$OUT/descs.txt" + res=$(find_close "$raw" || true) + if [ -n "$res" ]; then + cx=$(echo "$res" | awk '{print $1}'); cy=$(echo "$res" | awk '{print $2}'); act=$(echo "$res" | awk '{print $3}') + $ADB shell screencap -p /sdcard/t.png 2>/dev/null; $ADB pull /sdcard/t.png "$OUT/before.png" 2>/dev/null + echo "[tap] close button = '$act' at ($cx,$cy); tapping…" + $ADB shell input tap "$cx" "$cy" + sleep 3 + if $ADB exec-out uiautomator dump /dev/tty 2>/dev/null | grep -q 'action:'; then + echo "[tap] paywall STILL present" + else + echo "[tap] paywall GONE" + fi + exit 0 + fi + echo "[tap] no standalone close node yet (iter $i); descs:"; cat "$OUT/descs.txt" + else + echo "[tap] paywall not detected (iter $i)" + fi + sleep 1 +done +echo "[tap] close never found"; exit 1 diff --git a/purchasely/example/integration_test/tools/tap_purchase.sh b/purchasely/example/integration_test/tools/tap_purchase.sh new file mode 100755 index 00000000..2ca83d22 --- /dev/null +++ b/purchasely/example/integration_test/tools/tap_purchase.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Host-side UI driver for interceptor_trigger_test.dart. +# +# Polls the device UI for the Purchasely purchase button (content-desc contains +# "action:purchase") and taps its center. Run it concurrently with the test: +# bash integration_test/tools/tap_purchase.sh emulator-5554 & +# flutter test integration_test/interceptor_trigger_test.dart -d emulator-5554 +# +# Verbose per-iteration logging + dump retry: `uiautomator dump` can transiently +# fail with "could not get idle state" while the paywall is still animating / +# loading (more common on the slow CI emulator), so we retry the dump and log +# every iteration's outcome to survive being killed when the test ends. +# +# Exits 0 after a successful tap, 1 on timeout. +set -uo pipefail + +DEV="${1:-emulator-5554}" +DESC="action:purchase" +DUMP_DEV="/sdcard/uidump_tap.xml" +DUMP_LOCAL="/tmp/uidump_tap_${DEV//[^a-zA-Z0-9]/_}.xml" + +dump_ui() { + local out + for _ in 1 2 3; do + out=$(adb -s "$DEV" exec-out uiautomator dump "$DUMP_DEV" 2>&1) + if echo "$out" | grep -q "dumped to"; then + adb -s "$DEV" pull "$DUMP_DEV" "$DUMP_LOCAL" >/dev/null 2>&1 && return 0 + fi + sleep 1 + done + echo " dump failed: $out" + return 1 +} + +for i in $(seq 1 90); do + if dump_ui; then + coords=$(python3 - "$DESC" "$DUMP_LOCAL" <<'PY' +import sys, re +desc, path = sys.argv[1], sys.argv[2] +try: + xml = open(path, encoding='utf-8').read() +except Exception: + sys.exit(0) +for m in re.finditer(r']*>', xml): + tag = m.group(0) + cd = re.search(r'content-desc="([^"]*)"', tag) + if cd and desc in cd.group(1): + b = re.search(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', tag) + if b: + x1, y1, x2, y2 = map(int, b.groups()) + print((x1 + x2) // 2, (y1 + y2) // 2) + break +PY +) + if [ -n "$coords" ]; then + echo "[tap_purchase] found '$DESC' at $coords (iter $i), tapping…" + adb -s "$DEV" shell input tap $coords + echo "[tap_purchase] tapped ✓" + exit 0 + else + n=$(grep -c '/dev/null || echo 0) + echo "[tap_purchase] iter $i: dump ok ($n nodes), no '$DESC' yet" + if [ "$i" = "5" ]; then + echo " [diag] focus: $(adb -s "$DEV" shell dumpsys window 2>/dev/null | grep -E 'mCurrentFocus|mFocusedApp' | tr -d '\r')" + echo " [diag] dump head: $(head -c 1200 "$DUMP_LOCAL" 2>/dev/null | tr -d '\n')" + fi + fi + else + echo "[tap_purchase] iter $i: dump unavailable, retrying" + fi + sleep 1 +done +echo "[tap_purchase] button '$DESC' not found after polling" +exit 1 diff --git a/purchasely/example/integration_test/tools/tap_purchase_ios.sh b/purchasely/example/integration_test/tools/tap_purchase_ios.sh new file mode 100755 index 00000000..81ed2824 --- /dev/null +++ b/purchasely/example/integration_test/tools/tap_purchase_ios.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Host-side UI driver for interceptor_trigger_ios_test.dart. +# +# The Purchasely iOS paywall is custom-rendered: its accessibility tree exposes +# only StaticText elements (AXLabel + frame), NOT interactive elements with +# accessibility identifiers. So — unlike Android where uiautomator sees the +# content-desc "action:purchase" — on iOS we locate the purchase CTA by its +# visible label and tap the centre of its frame (the label overlays the button). +# +# Uses `idb` (pip install fb-idb) + idb-companion (brew install idb-companion). +# `ui describe-all --json` returns a FLAT array; a wrapper sets up an asyncio +# event loop before idb's main() runs, fixing the RuntimeError on Python 3.12+. +# +# Run concurrently with the test: +# bash integration_test/tools/tap_purchase_ios.sh & +# flutter test integration_test/interceptor_trigger_ios_test.dart -d +# +# Exits 0 after a successful tap, 1 on timeout. +set -uo pipefail + +UDID="${1:?usage: $0 }" +# Purchase CTA labels for the integration_test_audiences placement (exact match, +# case-insensitive). The paywall's purchase button is labelled "Continue". +CTA_LABELS="Continue|Continuer|Subscribe|S'abonner|Unlock now" + +run_idb() { + python3 - "$@" <<'__PYEOF__' +import asyncio, sys +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +from idb.cli.main import main +sys.exit(main()) +__PYEOF__ +} + +find_and_tap() { + local raw + raw=$(run_idb ui describe-all --json --udid "$UDID" 2>/dev/null) || return 1 + + # Pass the JSON via an env var, NOT stdin: `python3 - <&1 + echo "[tap_purchase_ios] tapped ✓" + return 0 +} + +# Tap the CTA repeatedly once found: a single tap occasionally doesn't register +# (paywall not yet interactive, or the label centre lands on padding). The +# purchase interceptor returns SUCCESS, so re-tapping is harmless and just gives +# the interceptor more chances to fire within the test's poll window. +taps=0 +for i in $(seq 1 90); do + if find_and_tap; then + taps=$((taps + 1)) + [ "$taps" -ge 8 ] && exit 0 + sleep 2 + else + echo "[tap_purchase_ios] purchase CTA not found yet (iter $i/90), retrying…" + sleep 1 + fi +done + +if [ "$taps" -gt 0 ]; then exit 0; fi +echo "[tap_purchase_ios] purchase CTA ($CTA_LABELS) not found after 90 s" +exit 1 diff --git a/purchasely/example/ios/Flutter/AppFrameworkInfo.plist b/purchasely/example/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..391a902b 100644 --- a/purchasely/example/ios/Flutter/AppFrameworkInfo.plist +++ b/purchasely/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/purchasely/example/ios/Podfile b/purchasely/example/ios/Podfile index 9361c158..b7fef30d 100644 --- a/purchasely/example/ios/Podfile +++ b/purchasely/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.4' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -32,6 +32,9 @@ target 'Runner' do use_frameworks! use_modular_headers! + # Purchasely 6.0.0-rc.1 is published on the public CocoaPods trunk, so the + # plugin's `s.dependency 'Purchasely', '6.0.0-rc.1'` resolves from there — no + # development pod needed. flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do @@ -43,5 +46,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end end end diff --git a/purchasely/example/ios/Podfile.lock b/purchasely/example/ios/Podfile.lock index 9b20b6b9..1f63c635 100644 --- a/purchasely/example/ios/Podfile.lock +++ b/purchasely/example/ios/Podfile.lock @@ -1,12 +1,15 @@ PODS: - Flutter (1.0.0) - - Purchasely (5.7.4) - - purchasely_flutter (1.2.4): + - integration_test (0.0.1): - Flutter - - Purchasely (= 5.7.4) + - Purchasely (6.0.0-rc.2) + - purchasely_flutter (6.0.0-rc.2): + - Flutter + - Purchasely (= 6.0.0-rc.2) DEPENDENCIES: - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - purchasely_flutter (from `.symlinks/plugins/purchasely_flutter/ios`) SPEC REPOS: @@ -16,14 +19,17 @@ SPEC REPOS: EXTERNAL SOURCES: Flutter: :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" purchasely_flutter: :path: ".symlinks/plugins/purchasely_flutter/ios" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - Purchasely: 1d742186c612c6b7e7a6f0e67952762a29d42920 - purchasely_flutter: 5a3d1fcdab0d2223e16fe7205aa56aca38441655 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + Purchasely: 055a06197235922eb9e888a3d903931996b31add + purchasely_flutter: cc1e588bd461ce726a3ff9beced1b5ed5b4c5454 -PODFILE CHECKSUM: 4a5d3c75c41739c31c5593a2d45e26f203a0b464 +PODFILE CHECKSUM: a6de5c5f685b37107148dc7191f9be5864b9359e COCOAPODS: 1.16.2 diff --git a/purchasely/example/ios/Runner.xcodeproj/project.pbxproj b/purchasely/example/ios/Runner.xcodeproj/project.pbxproj index 6a815cec..9a1b963e 100644 --- a/purchasely/example/ios/Runner.xcodeproj/project.pbxproj +++ b/purchasely/example/ios/Runner.xcodeproj/project.pbxproj @@ -62,10 +62,10 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A2B8E1D9C17DFADD77A067CA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - AABBCC00112233445566778D /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AABBCC001122334455667789 /* SwiftPurchaselyFlutterPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftPurchaselyFlutterPluginTests.swift; sourceTree = ""; }; - AABBCC00112233445566778E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AABBCC00112233445566778B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AABBCC00112233445566778D /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AABBCC00112233445566778E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AABBCC00112233445566778F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; AABBCC001122334455667790 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; AABBCC001122334455667791 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; @@ -186,8 +186,8 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 5E2527460D91B9A57AB121E4 /* [CP] Embed Pods Frameworks */, - 587A508AE6F78220F2CACBE5 /* [CP] Copy Pods Resources */, + C586E1234D0CA1014A046D42 /* [CP] Embed Pods Frameworks */, + C49231D754FA39261B2A01AD /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -315,74 +315,74 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 587A508AE6F78220F2CACBE5 /* [CP] Copy Pods Resources */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; + buildActionMask = 12; files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + name = "Run Script"; + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; - 5E2527460D91B9A57AB121E4 /* [CP] Embed Pods Frameworks */ = { + AABBCC001122334455667796 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + C49231D754FA39261B2A01AD /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; - buildActionMask = 12; + buildActionMask = 2147483647; files = ( ); - inputPaths = ( + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "Run Script"; - outputPaths = ( + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; }; - AABBCC001122334455667796 /* [CP] Check Pods Manifest.lock */ = { + C586E1234D0CA1014A046D42 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -476,7 +476,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -556,7 +556,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -605,7 +605,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -676,7 +676,7 @@ DEVELOPMENT_TEAM = XL327LBYNK; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -704,7 +704,7 @@ DEVELOPMENT_TEAM = XL327LBYNK; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -731,7 +731,7 @@ DEVELOPMENT_TEAM = XL327LBYNK; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/purchasely/example/ios/Runner/AppDelegate.swift b/purchasely/example/ios/Runner/AppDelegate.swift index b6363034..62666446 100644 --- a/purchasely/example/ios/Runner/AppDelegate.swift +++ b/purchasely/example/ios/Runner/AppDelegate.swift @@ -1,5 +1,5 @@ -import UIKit import Flutter +import UIKit @main @objc class AppDelegate: FlutterAppDelegate { diff --git a/purchasely/example/ios/Runner/Info.plist b/purchasely/example/ios/Runner/Info.plist index c19899c8..e3ac15ad 100644 --- a/purchasely/example/ios/Runner/Info.plist +++ b/purchasely/example/ios/Runner/Info.plist @@ -26,6 +26,29 @@ LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -45,7 +68,5 @@ UIViewControllerBasedStatusBarAppearance - UIApplicationSupportsIndirectInputEvents - diff --git a/purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift b/purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift index fcbe9d13..e88f7935 100644 --- a/purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift +++ b/purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift @@ -1,1298 +1,97 @@ import XCTest - +import Purchasely @testable import purchasely_flutter -// MARK: - Mock Classes - -/// Mock Flutter result handler for capturing results -class MockFlutterResult { - var capturedResult: Any? - var resultCalled = false - - func result(_ result: Any?) { - self.capturedResult = result - self.resultCalled = true - } - - var handler: (Any?) -> Void { - return { [weak self] result in - self?.result(result) +// Unit tests for the Purchasely Flutter iOS bridge against the 6.0 native SDK. +// +// These are intentionally focused on the v6 native API surface that +// `SwiftPurchaselyFlutterPlugin` depends on: the fluent init builder, the +// `PLYPresentationBuilder` factories, the `PLYPresentationOutcome` shape (incl. +// the v6 `closeReason`), the interceptor result/action enums and the display +// modes. If the native API drifts, this test target fails to COMPILE — which +// surfaces the break before runtime, mirroring the Dart/Android unit suites. +// +// CocoaPods test targets require `XCTestCase` (not Swift Testing). +class SwiftPurchaselyFlutterPluginTests: XCTestCase { + + // MARK: - Plugin type + + func testPluginTypeIsReachable() { + // The public plugin entry point must exist and be referenceable. + XCTAssertFalse(String(describing: SwiftPurchaselyFlutterPlugin.self).isEmpty) } - } -} - -/// Mock Flutter method call for testing -class MockFlutterMethodCall { - let method: String - let arguments: Any? - - init(method: String, arguments: Any? = nil) { - self.method = method - self.arguments = arguments - } -} - -// MARK: - FlutterPLYAttribute Tests -class FlutterPLYAttributeTests: XCTestCase { - - func testAllAttributeRawValues() { - // Test that all enum cases have expected raw values - XCTAssertEqual(FlutterPLYAttribute.firebaseAppInstanceId.rawValue, 0) - XCTAssertEqual(FlutterPLYAttribute.airshipChannelId.rawValue, 1) - XCTAssertEqual(FlutterPLYAttribute.airshipUserId.rawValue, 2) - XCTAssertEqual(FlutterPLYAttribute.batchInstallationId.rawValue, 3) - XCTAssertEqual(FlutterPLYAttribute.adjustId.rawValue, 4) - XCTAssertEqual(FlutterPLYAttribute.appsflyerId.rawValue, 5) - XCTAssertEqual(FlutterPLYAttribute.mixpanelDistinctId.rawValue, 6) - XCTAssertEqual(FlutterPLYAttribute.cleverTapId.rawValue, 7) - XCTAssertEqual(FlutterPLYAttribute.sendinblueUserEmail.rawValue, 8) - XCTAssertEqual(FlutterPLYAttribute.iterableUserEmail.rawValue, 9) - XCTAssertEqual(FlutterPLYAttribute.iterableUserId.rawValue, 10) - XCTAssertEqual(FlutterPLYAttribute.atInternetIdClient.rawValue, 11) - XCTAssertEqual(FlutterPLYAttribute.mParticleUserId.rawValue, 12) - XCTAssertEqual(FlutterPLYAttribute.customerioUserId.rawValue, 13) - XCTAssertEqual(FlutterPLYAttribute.customerioUserEmail.rawValue, 14) - XCTAssertEqual(FlutterPLYAttribute.branchUserDeveloperIdentity.rawValue, 15) - XCTAssertEqual(FlutterPLYAttribute.amplitudeUserId.rawValue, 16) - XCTAssertEqual(FlutterPLYAttribute.amplitudeDeviceId.rawValue, 17) - XCTAssertEqual(FlutterPLYAttribute.moengageUniqueId.rawValue, 18) - XCTAssertEqual(FlutterPLYAttribute.oneSignalExternalId.rawValue, 19) - XCTAssertEqual(FlutterPLYAttribute.batchCustomUserId.rawValue, 20) - } - - func testAttributeInitFromRawValue() { - XCTAssertEqual(FlutterPLYAttribute(rawValue: 0), .firebaseAppInstanceId) - XCTAssertEqual(FlutterPLYAttribute(rawValue: 5), .appsflyerId) - XCTAssertEqual(FlutterPLYAttribute(rawValue: 20), .batchCustomUserId) - XCTAssertNil(FlutterPLYAttribute(rawValue: 100)) - XCTAssertNil(FlutterPLYAttribute(rawValue: -1)) - } - - func testTotalAttributeCount() { - // Ensure we have exactly 21 attributes (0-20) - var count = 0 - for i in 0...100 { - if FlutterPLYAttribute(rawValue: i) != nil { - count += 1 - } + // MARK: - Initialization builder (v6) + + func testInitBuilderChainCompilesAndReturnsBuilder() { + // Mirrors the chain used by `SwiftPurchaselyFlutterPlugin.start(...)`. + let builder = Purchasely.apiKey("test-api-key") + .appTechnology(.flutter) + .sdkBridgeVersion("6.0.0-rc.1") + .runningMode(.full) + .logLevel(.debug) + .storekitSettings(.storeKit2) + XCTAssertNotNil(builder) } - XCTAssertEqual(count, 21) - } -} - -// MARK: - FlutterError Extension Tests - -class FlutterErrorExtensionTests: XCTestCase { - - func testNilArgumentError() { - let error = FlutterError.nilArgument - XCTAssertEqual(error.code, "argument.nil") - XCTAssertEqual(error.message, "Expect an argument when invoking channel method, but it is nil.") - XCTAssertNil(error.details) - } - - func testFailedArgumentFieldString() { - let error = FlutterError.failedArgumentField("apiKey", type: String.self) - XCTAssertEqual(error.code, "argument.failedField") - XCTAssertTrue(error.message?.contains("apiKey") ?? false) - XCTAssertTrue(error.message?.contains("String") ?? false) - XCTAssertEqual(error.details as? String, "apiKey") - } - func testFailedArgumentFieldInt() { - let error = FlutterError.failedArgumentField("count", type: Int.self) - XCTAssertEqual(error.code, "argument.failedField") - XCTAssertTrue(error.message?.contains("count") ?? false) - XCTAssertTrue(error.message?.contains("Int") ?? false) - XCTAssertEqual(error.details as? String, "count") - } - - func testFailedArgumentFieldArray() { - let error = FlutterError.failedArgumentField("items", type: [String].self) - XCTAssertEqual(error.code, "argument.failedField") - XCTAssertTrue(error.message?.contains("items") ?? false) - XCTAssertEqual(error.details as? String, "items") - } - - func testErrorWithCode() { - let underlyingError = NSError( - domain: "TestDomain", code: 42, userInfo: [NSLocalizedDescriptionKey: "Test error"]) - let error = FlutterError.error( - code: "-1", message: "Something went wrong", error: underlyingError) - - XCTAssertEqual(error.code, "-1") - XCTAssertEqual(error.message, "Something went wrong") - XCTAssertEqual(error.details as? String, "Test error") - } - - func testErrorWithNilError() { - let error = FlutterError.error(code: "500", message: "Server error", error: nil) - - XCTAssertEqual(error.code, "500") - XCTAssertEqual(error.message, "Server error") - XCTAssertNil(error.details) - } - - func testErrorWithNilMessage() { - let error = FlutterError.error(code: "400", message: nil, error: nil) - - XCTAssertEqual(error.code, "400") - XCTAssertNil(error.message) - XCTAssertNil(error.details) - } -} - -// MARK: - SwiftEventHandler Tests - -class SwiftEventHandlerTests: XCTestCase { - - var eventHandler: SwiftEventHandler! - - override func setUp() { - super.setUp() - eventHandler = SwiftEventHandler() - } - - override func tearDown() { - eventHandler = nil - super.tearDown() - } - - func testInitialState() { - XCTAssertNil(eventHandler.eventSink) - } - - func testOnListenSetsEventSink() { - var receivedEvents: [Any] = [] - let mockEventSink: FlutterEventSink = { event in - if let event = event { - receivedEvents.append(event) - } + func testRunningModeCases() { + // The bridge maps the Dart "full"/"observer" strings onto these. + XCTAssertNotEqual(PLYRunningMode.full, PLYRunningMode.observer) } - let error = eventHandler.onListen(withArguments: nil, eventSink: mockEventSink) - - XCTAssertNil(error) - XCTAssertNotNil(eventHandler.eventSink) - } - - func testOnCancelClearsEventSink() { - // First set up the event sink - let mockEventSink: FlutterEventSink = { _ in } - _ = eventHandler.onListen(withArguments: nil, eventSink: mockEventSink) - XCTAssertNotNil(eventHandler.eventSink) + // MARK: - Presentation builder factories (v6) - // Now cancel - let error = eventHandler.onCancel(withArguments: nil) - - XCTAssertNil(error) - XCTAssertNil(eventHandler.eventSink) - } - - func testOnListenWithArguments() { - let arguments: [String: Any] = ["key": "value"] - let mockEventSink: FlutterEventSink = { _ in } - - let error = eventHandler.onListen(withArguments: arguments, eventSink: mockEventSink) - - XCTAssertNil(error) - } - - func testOnCancelWithArguments() { - let arguments: [String: Any] = ["key": "value"] - - let error = eventHandler.onCancel(withArguments: arguments) - - XCTAssertNil(error) - } -} - -// MARK: - SwiftPurchaseHandler Tests - -class SwiftPurchaseHandlerTests: XCTestCase { - - var purchaseHandler: SwiftPurchaseHandler! - - override func setUp() { - super.setUp() - purchaseHandler = SwiftPurchaseHandler() - } - - override func tearDown() { - purchaseHandler = nil - super.tearDown() - } - - func testInitialState() { - XCTAssertNil(purchaseHandler.eventSink) - } - - func testOnListenSetsEventSink() { - let mockEventSink: FlutterEventSink = { _ in } - - let error = purchaseHandler.onListen(withArguments: nil, eventSink: mockEventSink) - - XCTAssertNil(error) - XCTAssertNotNil(purchaseHandler.eventSink) - } - - func testOnCancelClearsEventSink() { - let mockEventSink: FlutterEventSink = { _ in } - _ = purchaseHandler.onListen(withArguments: nil, eventSink: mockEventSink) - XCTAssertNotNil(purchaseHandler.eventSink) - - let error = purchaseHandler.onCancel(withArguments: nil) - - XCTAssertNil(error) - // Note: SwiftPurchaseHandler's onCancel doesn't clear the eventSink, it only removes the observer - // This is the actual behavior of the implementation - } - - func testPurchasePerformedTriggersEventSink() { - var receivedEvent = false - let expectation = XCTestExpectation(description: "Event sink called") - - let mockEventSink: FlutterEventSink = { event in - receivedEvent = true - expectation.fulfill() + func testPresentationBuilderFactories() { + XCTAssertNotNil(PLYPresentationBuilder.default()) + XCTAssertNotNil(PLYPresentationBuilder.from(placementId: "placement")) + // `from(screenId:)` is the v6 replacement for the removed + // `from(presentationId:)` the bridge used to call. + XCTAssertNotNil(PLYPresentationBuilder.from(screenId: "screen")) } - _ = purchaseHandler.onListen(withArguments: nil, eventSink: mockEventSink) - purchaseHandler.purchasePerformed() - - wait(for: [expectation], timeout: 1.0) - XCTAssertTrue(receivedEvent) - } - - func testPurchasePerformedWithNoEventSinkDoesNotCrash() { - // Should not crash when eventSink is nil - XCTAssertNil(purchaseHandler.eventSink) - purchaseHandler.purchasePerformed() - // Test passes if no crash occurs - } -} - -// MARK: - UserAttributesHandler Tests - -class UserAttributesHandlerTests: XCTestCase { - - var userAttributesHandler: UserAttributesHandler! - - override func setUp() { - super.setUp() - userAttributesHandler = UserAttributesHandler() - } - - override func tearDown() { - userAttributesHandler = nil - super.tearDown() - } - - func testInitialState() { - XCTAssertNil(userAttributesHandler.eventSink) - } - - func testOnListenSetsEventSink() { - let mockEventSink: FlutterEventSink = { _ in } - - let error = userAttributesHandler.onListen(withArguments: nil, eventSink: mockEventSink) - - XCTAssertNil(error) - XCTAssertNotNil(userAttributesHandler.eventSink) - } - - func testOnCancelClearsEventSink() { - let mockEventSink: FlutterEventSink = { _ in } - _ = userAttributesHandler.onListen(withArguments: nil, eventSink: mockEventSink) - XCTAssertNotNil(userAttributesHandler.eventSink) - - let error = userAttributesHandler.onCancel(withArguments: nil) - - XCTAssertNil(error) - XCTAssertNil(userAttributesHandler.eventSink) - } -} - -// MARK: - Method Call Argument Parsing Tests - -class MethodCallArgumentParsingTests: XCTestCase { - - func testStartArgumentsValid() { - let arguments: [String: Any] = [ - "apiKey": "test_api_key", - "logLevel": 1, - "userId": "user123", - "runningMode": 0, - "storeKit1": false, - ] - - XCTAssertEqual(arguments["apiKey"] as? String, "test_api_key") - XCTAssertEqual(arguments["logLevel"] as? Int, 1) - XCTAssertEqual(arguments["userId"] as? String, "user123") - XCTAssertEqual(arguments["runningMode"] as? Int, 0) - XCTAssertEqual(arguments["storeKit1"] as? Bool, false) - } - - func testStartArgumentsMissingApiKey() { - let arguments: [String: Any] = [ - "logLevel": 1, - "userId": "user123", - ] - - XCTAssertNil(arguments["apiKey"] as? String) - } - - func testUserLoginArgumentsValid() { - let arguments: [String: Any] = [ - "userId": "user123" - ] - - XCTAssertEqual(arguments["userId"] as? String, "user123") - } - - func testUserLoginArgumentsMissingUserId() { - let arguments: [String: Any] = [:] - - XCTAssertNil(arguments["userId"] as? String) - } - - func testPresentationArgumentsValid() { - let arguments: [String: Any] = [ - "presentationVendorId": "presentation123", - "contentId": "content456", - "isFullscreen": true, - ] - - XCTAssertEqual(arguments["presentationVendorId"] as? String, "presentation123") - XCTAssertEqual(arguments["contentId"] as? String, "content456") - XCTAssertEqual(arguments["isFullscreen"] as? Bool, true) - } - - func testPlacementArgumentsValid() { - let arguments: [String: Any] = [ - "placementVendorId": "placement123", - "contentId": "content456", - ] - - XCTAssertEqual(arguments["placementVendorId"] as? String, "placement123") - XCTAssertEqual(arguments["contentId"] as? String, "content456") - } - - func testProductIdentifierArguments() { - let arguments: [String: Any] = [ - "productVendorId": "product123", - "presentationVendorId": "presentation456", - "contentId": "content789", - ] - - XCTAssertEqual(arguments["productVendorId"] as? String, "product123") - XCTAssertEqual(arguments["presentationVendorId"] as? String, "presentation456") - XCTAssertEqual(arguments["contentId"] as? String, "content789") - } - - func testPlanIdentifierArguments() { - let arguments: [String: Any] = [ - "planVendorId": "plan123", - "presentationVendorId": "presentation456", - ] - - XCTAssertEqual(arguments["planVendorId"] as? String, "plan123") - XCTAssertEqual(arguments["presentationVendorId"] as? String, "presentation456") - } - - func testDeeplinkArguments() { - let arguments: [String: Any] = [ - "deeplink": "https://example.com/deeplink" - ] - - let deeplink = arguments["deeplink"] as? String - XCTAssertNotNil(deeplink) - XCTAssertNotNil(URL(string: deeplink!)) - } - - func testSetAttributeArguments() { - let arguments: [String: Any] = [ - "attribute": 5, - "value": "test_value", - ] - - let attributeRaw = arguments["attribute"] as? Int - XCTAssertNotNil(attributeRaw) - XCTAssertNotNil(FlutterPLYAttribute(rawValue: attributeRaw!)) - XCTAssertEqual(arguments["value"] as? String, "test_value") - } - - func testUserAttributeStringArguments() { - let arguments: [String: Any] = [ - "key": "user_name", - "value": "John Doe", - "processingLegalBasis": "ESSENTIAL", - ] - - XCTAssertEqual(arguments["key"] as? String, "user_name") - XCTAssertEqual(arguments["value"] as? String, "John Doe") - XCTAssertEqual(arguments["processingLegalBasis"] as? String, "ESSENTIAL") - } - - func testUserAttributeIntArguments() { - let arguments: [String: Any] = [ - "key": "user_age", - "value": 25, - "processingLegalBasis": "OPTIONAL", - ] - - XCTAssertEqual(arguments["key"] as? String, "user_age") - XCTAssertEqual(arguments["value"] as? Int, 25) - } - - func testUserAttributeDoubleArguments() { - let arguments: [String: Any] = [ - "key": "user_score", - "value": 98.5, - ] - - XCTAssertEqual(arguments["key"] as? String, "user_score") - XCTAssertEqual(arguments["value"] as? Double, 98.5) - } - - func testUserAttributeBoolArguments() { - let arguments: [String: Any] = [ - "key": "is_premium", - "value": true, - ] - - XCTAssertEqual(arguments["key"] as? String, "is_premium") - XCTAssertEqual(arguments["value"] as? Bool, true) - } - - func testUserAttributeDateArguments() { - let arguments: [String: Any] = [ - "key": "birth_date", - "value": "1990-05-15T00:00:00.000Z", - ] - - XCTAssertEqual(arguments["key"] as? String, "birth_date") - - let dateString = arguments["value"] as? String - XCTAssertNotNil(dateString) - - let dateFormatter = DateFormatter() - dateFormatter.timeZone = TimeZone(identifier: "GMT") - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - - let date = dateFormatter.date(from: dateString!) - XCTAssertNotNil(date) - } - - func testUserAttributeArrayArguments() { - let stringArrayArguments: [String: Any] = [ - "key": "favorite_colors", - "value": ["red", "blue", "green"], - ] - - XCTAssertEqual(stringArrayArguments["key"] as? String, "favorite_colors") - XCTAssertEqual(stringArrayArguments["value"] as? [String], ["red", "blue", "green"]) - - let intArrayArguments: [String: Any] = [ - "key": "scores", - "value": [100, 95, 88], - ] - - XCTAssertEqual(intArrayArguments["value"] as? [Int], [100, 95, 88]) - - let doubleArrayArguments: [String: Any] = [ - "key": "ratings", - "value": [4.5, 3.8, 4.9], - ] - - XCTAssertEqual(doubleArrayArguments["value"] as? [Double], [4.5, 3.8, 4.9]) - - let boolArrayArguments: [String: Any] = [ - "key": "flags", - "value": [true, false, true], - ] - - XCTAssertEqual(boolArrayArguments["value"] as? [Bool], [true, false, true]) - } - - func testThemeModeArguments() { - let arguments: [String: Any] = [ - "mode": 0 - ] - - XCTAssertEqual(arguments["mode"] as? Int, 0) - } - - func testLogLevelArguments() { - let arguments: [String: Any] = [ - "logLevel": 2 - ] - - XCTAssertEqual(arguments["logLevel"] as? Int, 2) - } - - func testLanguageArguments() { - let arguments: [String: Any] = [ - "language": "fr" - ] - - XCTAssertEqual(arguments["language"] as? String, "fr") - } - - func testPurchaseWithPlanArguments() { - let arguments: [String: Any] = [ - "vendorId": "plan_premium", - "contentId": "content123", - "offerId": "offer456", - ] - - XCTAssertEqual(arguments["vendorId"] as? String, "plan_premium") - XCTAssertEqual(arguments["contentId"] as? String, "content123") - XCTAssertEqual(arguments["offerId"] as? String, "offer456") - } - - func testSignPromotionalOfferArguments() { - let arguments: [String: Any] = [ - "storeProductId": "com.app.subscription", - "storeOfferId": "offer_id_123", - ] - - XCTAssertEqual(arguments["storeProductId"] as? String, "com.app.subscription") - XCTAssertEqual(arguments["storeOfferId"] as? String, "offer_id_123") - } - - func testDynamicOfferingArguments() { - let arguments: [String: Any] = [ - "reference": "ref123", - "planVendorId": "plan456", - "offerVendorId": "offer789", - ] - - XCTAssertEqual(arguments["reference"] as? String, "ref123") - XCTAssertEqual(arguments["planVendorId"] as? String, "plan456") - XCTAssertEqual(arguments["offerVendorId"] as? String, "offer789") - } - - func testRevokeDataProcessingConsentArguments() { - let arguments: [String: Any] = [ - "purposes": ["ANALYTICS", "CAMPAIGNS"] - ] - - let purposes = arguments["purposes"] as? [String] - XCTAssertEqual(purposes, ["ANALYTICS", "CAMPAIGNS"]) - } - - func testDebugModeArguments() { - let arguments: [String: Any] = [ - "debugMode": true - ] - - XCTAssertEqual(arguments["debugMode"] as? Bool, true) - } -} - -// MARK: - Data Processing Purpose Parsing Tests - -class DataProcessingPurposeParsingTests: XCTestCase { - - func testParseAnalytics() { - let value = "ANALYTICS" - XCTAssertEqual(value, "ANALYTICS") - } - - func testParseIdentifiedAnalytics() { - let value = "IDENTIFIED_ANALYTICS" - XCTAssertEqual(value, "IDENTIFIED_ANALYTICS") - } + // MARK: - Outcome shape (v6) - func testParseCampaigns() { - let value = "CAMPAIGNS" - XCTAssertEqual(value, "CAMPAIGNS") - } - - func testParsePersonalization() { - let value = "PERSONALIZATION" - XCTAssertEqual(value, "PERSONALIZATION") - } - - func testParseThirdPartyIntegrations() { - let value = "THIRD_PARTY_INTEGRATIONS" - XCTAssertEqual(value, "THIRD_PARTY_INTEGRATIONS") - } - - func testParseAllNonEssentials() { - let value = "ALL_NON_ESSENTIALS" - XCTAssertEqual(value, "ALL_NON_ESSENTIALS") - } - - func testParsePurposesArray() { - let purposes = ["ANALYTICS", "CAMPAIGNS", "PERSONALIZATION"] - XCTAssertEqual(purposes.count, 3) - XCTAssertTrue(purposes.contains("ANALYTICS")) - XCTAssertTrue(purposes.contains("CAMPAIGNS")) - XCTAssertTrue(purposes.contains("PERSONALIZATION")) - } - - func testProcessingLegalBasisEssential() { - let value = "ESSENTIAL" - XCTAssertEqual(value, "ESSENTIAL") - } - - func testProcessingLegalBasisOptional() { - let value = "OPTIONAL" - XCTAssertNotEqual(value, "ESSENTIAL") - } -} - -// MARK: - Presentation Map Tests - -class PresentationMapTests: XCTestCase { - - func testPresentationMapStructure() { - let presentationMap: [String: Any] = [ - "id": "presentation123", - "placementId": "placement456", - "type": "normal", - ] - - XCTAssertEqual(presentationMap["id"] as? String, "presentation123") - XCTAssertEqual(presentationMap["placementId"] as? String, "placement456") - XCTAssertEqual(presentationMap["type"] as? String, "normal") - } - - func testNestedPresentationInArguments() { - let arguments: [String: Any] = [ - "presentation": [ - "id": "pres123", - "placementId": "place456", - ], - "isFullscreen": true, - ] - - let presentation = arguments["presentation"] as? [String: Any] - XCTAssertNotNil(presentation) - XCTAssertEqual(presentation?["id"] as? String, "pres123") - XCTAssertEqual(presentation?["placementId"] as? String, "place456") - XCTAssertEqual(arguments["isFullscreen"] as? Bool, true) - } -} - -// MARK: - Method Name Tests - -class MethodNameTests: XCTestCase { - - let allMethodNames = [ - "start", - "close", - "setDefaultPresentationResultHandler", - "fetchPresentation", - "presentPresentation", - "clientPresentationDisplayed", - "clientPresentationClosed", - "presentPresentationWithIdentifier", - "presentProductWithIdentifier", - "presentPlanWithIdentifier", - "presentPresentationForPlacement", - "restoreAllProducts", - "silentRestoreAllProducts", - "synchronize", - "getAnonymousUserId", - "userLogin", - "userLogout", - "readyToOpenDeeplink", - "setLogLevel", - "productWithIdentifier", - "planWithIdentifier", - "allProducts", - "purchaseWithPlanVendorId", - "isDeeplinkHandled", - "userSubscriptions", - "userSubscriptionsHistory", - "presentSubscriptions", - "setThemeMode", - "setAttribute", - "setPaywallActionInterceptor", - "setLanguage", - "onProcessAction", - "userDidConsumeSubscriptionContent", - "setUserAttributeWithString", - "setUserAttributeWithInt", - "setUserAttributeWithDouble", - "setUserAttributeWithBoolean", - "setUserAttributeWithDate", - "setUserAttributeWithStringArray", - "setUserAttributeWithIntArray", - "setUserAttributeWithDoubleArray", - "setUserAttributeWithBooleanArray", - "incrementUserAttribute", - "decrementUserAttribute", - "userAttribute", - "userAttributes", - "clearUserAttribute", - "clearUserAttributes", - "clearBuiltInAttributes", - "displaySubscriptionCancellationInstruction", - "isAnonymous", - "hidePresentation", - "showPresentation", - "closePresentation", - "signPromotionalOffer", - "isEligibleForIntroOffer", - "setDynamicOffering", - "getDynamicOfferings", - "removeDynamicOffering", - "clearDynamicOfferings", - "revokeDataProcessingConsent", - "setDebugMode", - ] - - func testAllMethodNamesAreDefined() { - XCTAssertGreaterThan(allMethodNames.count, 50) - } - - func testMethodNameStart() { - XCTAssertTrue(allMethodNames.contains("start")) - } - - func testMethodNameClose() { - XCTAssertTrue(allMethodNames.contains("close")) - } - - func testPresentationMethods() { - XCTAssertTrue(allMethodNames.contains("fetchPresentation")) - XCTAssertTrue(allMethodNames.contains("presentPresentation")) - XCTAssertTrue(allMethodNames.contains("presentPresentationWithIdentifier")) - XCTAssertTrue(allMethodNames.contains("presentPresentationForPlacement")) - XCTAssertTrue(allMethodNames.contains("hidePresentation")) - XCTAssertTrue(allMethodNames.contains("showPresentation")) - XCTAssertTrue(allMethodNames.contains("closePresentation")) - } - - func testUserMethods() { - XCTAssertTrue(allMethodNames.contains("userLogin")) - XCTAssertTrue(allMethodNames.contains("userLogout")) - XCTAssertTrue(allMethodNames.contains("getAnonymousUserId")) - XCTAssertTrue(allMethodNames.contains("isAnonymous")) - XCTAssertTrue(allMethodNames.contains("userSubscriptions")) - XCTAssertTrue(allMethodNames.contains("userSubscriptionsHistory")) - } - - func testAttributeMethods() { - XCTAssertTrue(allMethodNames.contains("setAttribute")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithString")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithInt")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithDouble")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithBoolean")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithDate")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithStringArray")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithIntArray")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithDoubleArray")) - XCTAssertTrue(allMethodNames.contains("setUserAttributeWithBooleanArray")) - XCTAssertTrue(allMethodNames.contains("incrementUserAttribute")) - XCTAssertTrue(allMethodNames.contains("decrementUserAttribute")) - XCTAssertTrue(allMethodNames.contains("userAttribute")) - XCTAssertTrue(allMethodNames.contains("userAttributes")) - XCTAssertTrue(allMethodNames.contains("clearUserAttribute")) - XCTAssertTrue(allMethodNames.contains("clearUserAttributes")) - XCTAssertTrue(allMethodNames.contains("clearBuiltInAttributes")) - } - - func testProductMethods() { - XCTAssertTrue(allMethodNames.contains("allProducts")) - XCTAssertTrue(allMethodNames.contains("productWithIdentifier")) - XCTAssertTrue(allMethodNames.contains("planWithIdentifier")) - XCTAssertTrue(allMethodNames.contains("presentProductWithIdentifier")) - XCTAssertTrue(allMethodNames.contains("presentPlanWithIdentifier")) - } - - func testPurchaseMethods() { - XCTAssertTrue(allMethodNames.contains("purchaseWithPlanVendorId")) - XCTAssertTrue(allMethodNames.contains("restoreAllProducts")) - XCTAssertTrue(allMethodNames.contains("silentRestoreAllProducts")) - } - - func testDynamicOfferingMethods() { - XCTAssertTrue(allMethodNames.contains("setDynamicOffering")) - XCTAssertTrue(allMethodNames.contains("getDynamicOfferings")) - XCTAssertTrue(allMethodNames.contains("removeDynamicOffering")) - XCTAssertTrue(allMethodNames.contains("clearDynamicOfferings")) - } - - func testConfigurationMethods() { - XCTAssertTrue(allMethodNames.contains("setLogLevel")) - XCTAssertTrue(allMethodNames.contains("setLanguage")) - XCTAssertTrue(allMethodNames.contains("setThemeMode")) - XCTAssertTrue(allMethodNames.contains("setDebugMode")) - } - - func testOtherMethods() { - XCTAssertTrue(allMethodNames.contains("synchronize")) - XCTAssertTrue(allMethodNames.contains("readyToOpenDeeplink")) - XCTAssertTrue(allMethodNames.contains("isDeeplinkHandled")) - XCTAssertTrue(allMethodNames.contains("setPaywallActionInterceptor")) - XCTAssertTrue(allMethodNames.contains("onProcessAction")) - XCTAssertTrue(allMethodNames.contains("signPromotionalOffer")) - XCTAssertTrue(allMethodNames.contains("isEligibleForIntroOffer")) - XCTAssertTrue(allMethodNames.contains("revokeDataProcessingConsent")) - } -} - -// MARK: - Paywall Action Tests - -class PaywallActionTests: XCTestCase { - - let allActions = [ - "login", - "purchase", - "close", - "close_all", - "restore", - "navigate", - "promo_code", - "open_presentation", - "open_placement", - "web_checkout", - ] - - func testAllActionsAreDefined() { - XCTAssertEqual(allActions.count, 10) - } - - func testLoginAction() { - XCTAssertTrue(allActions.contains("login")) - } - - func testPurchaseAction() { - XCTAssertTrue(allActions.contains("purchase")) - } - - func testCloseAction() { - XCTAssertTrue(allActions.contains("close")) - } - - func testCloseAllAction() { - XCTAssertTrue(allActions.contains("close_all")) - } - - func testRestoreAction() { - XCTAssertTrue(allActions.contains("restore")) - } - - func testNavigateAction() { - XCTAssertTrue(allActions.contains("navigate")) - } - - func testPromoCodeAction() { - XCTAssertTrue(allActions.contains("promo_code")) - } - - func testOpenPresentationAction() { - XCTAssertTrue(allActions.contains("open_presentation")) - } - - func testOpenPlacementAction() { - XCTAssertTrue(allActions.contains("open_placement")) - } - - func testWebCheckoutAction() { - XCTAssertTrue(allActions.contains("web_checkout")) - } -} - -// MARK: - User Attribute Type Formatting Tests - -class UserAttributeTypeFormattingTests: XCTestCase { - - func testStringTypeFormat() { - XCTAssertEqual("STRING", "STRING") - } - - func testBooleanTypeFormat() { - XCTAssertEqual("BOOLEAN", "BOOLEAN") - } - - func testIntTypeFormat() { - XCTAssertEqual("INT", "INT") - } - - func testFloatTypeFormat() { - XCTAssertEqual("FLOAT", "FLOAT") - } - - func testDateTypeFormat() { - XCTAssertEqual("DATE", "DATE") - } - - func testStringArrayTypeFormat() { - XCTAssertEqual("STRING_ARRAY", "STRING_ARRAY") - } - - func testIntArrayTypeFormat() { - XCTAssertEqual("INT_ARRAY", "INT_ARRAY") - } - - func testFloatArrayTypeFormat() { - XCTAssertEqual("FLOAT_ARRAY", "FLOAT_ARRAY") - } - - func testBooleanArrayTypeFormat() { - XCTAssertEqual("BOOLEAN_ARRAY", "BOOLEAN_ARRAY") - } - - func testDictionaryTypeFormat() { - XCTAssertEqual("DICTIONARY", "DICTIONARY") - } -} - -// MARK: - Date Formatter Tests - -class DateFormatterTests: XCTestCase { - - func testDateFormatterFormat() { - let dateFormatter = DateFormatter() - dateFormatter.timeZone = TimeZone(identifier: "GMT") - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - - // Test parsing - let dateString = "2023-12-25T10:30:00.000Z" - let date = dateFormatter.date(from: dateString) - XCTAssertNotNil(date) - - // Test formatting back - if let date = date { - let formattedString = dateFormatter.string(from: date) - XCTAssertEqual(formattedString, dateString) + func testOutcomeDefaultInitIsEmpty() { + // The bridge synthesises a "no purchase" outcome with the 0-arg init. + let outcome = PLYPresentationOutcome() + XCTAssertEqual(outcome.closeReason, .none) + XCTAssertNil(outcome.plan) + XCTAssertEqual(outcome.purchaseResult, .none) } - } - - func testDateFormatterInvalidFormat() { - let dateFormatter = DateFormatter() - dateFormatter.timeZone = TimeZone(identifier: "GMT") - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - - let invalidDateString = "25-12-2023" - let date = dateFormatter.date(from: invalidDateString) - XCTAssertNil(date) - } - - func testDateFormatterTimeZone() { - let dateFormatter = DateFormatter() - dateFormatter.timeZone = TimeZone(identifier: "GMT") - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - - XCTAssertEqual(dateFormatter.timeZone.identifier, "GMT") - } - - func testDateToFlutterFormatConversion() { - let dateFormatter = DateFormatter() - dateFormatter.timeZone = TimeZone(identifier: "GMT") - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - - let date = Date(timeIntervalSince1970: 0) // 1970-01-01 - let formattedString = dateFormatter.string(from: date) - XCTAssertEqual(formattedString, "1970-01-01T00:00:00.000Z") - } -} - -// MARK: - URL Validation Tests - -class URLValidationTests: XCTestCase { - - func testValidHTTPSUrl() { - let urlString = "https://example.com/deeplink" - let url = URL(string: urlString) - XCTAssertNotNil(url) - XCTAssertEqual(url?.scheme, "https") - } - - func testValidHTTPUrl() { - let urlString = "http://example.com/path" - let url = URL(string: urlString) - XCTAssertNotNil(url) - XCTAssertEqual(url?.scheme, "http") - } - - func testValidCustomSchemeUrl() { - let urlString = "myapp://purchasely/product/123" - let url = URL(string: urlString) - XCTAssertNotNil(url) - XCTAssertEqual(url?.scheme, "myapp") - } - - func testUrlWithQueryParameters() { - let urlString = "https://example.com/path?param1=value1¶m2=value2" - let url = URL(string: urlString) - XCTAssertNotNil(url) - XCTAssertNotNil(url?.query) - } - - func testInvalidUrl() { - // Empty string is an invalid URL - let urlString = "" - let url = URL(string: urlString) - XCTAssertNil(url) - } -} - -// MARK: - Event Channel Name Tests - -class EventChannelNameTests: XCTestCase { - - func testPurchaselyEventsChannelName() { - let channelName = "purchasely-events" - XCTAssertEqual(channelName, "purchasely-events") - } - - func testPurchaselyPurchasesChannelName() { - let channelName = "purchasely-purchases" - XCTAssertEqual(channelName, "purchasely-purchases") - } - - func testPurchaselyUserAttributesChannelName() { - let channelName = "purchasely-user-attributes" - XCTAssertEqual(channelName, "purchasely-user-attributes") - } - - func testMethodChannelName() { - let channelName = "purchasely" - XCTAssertEqual(channelName, "purchasely") - } - - func testNativeViewId() { - let viewId = "io.purchasely.purchasely_flutter/native_view" - XCTAssertEqual(viewId, "io.purchasely.purchasely_flutter/native_view") - } -} - -// MARK: - Result Value Tests - -class ResultValueTests: XCTestCase { - - func testPresentationResultMapStructure() { - let resultMap: [String: Any] = [ - "result": 0, - "plan": ["id": "plan123", "name": "Premium"], - ] - - XCTAssertEqual(resultMap["result"] as? Int, 0) - - let plan = resultMap["plan"] as? [String: Any] - XCTAssertNotNil(plan) - XCTAssertEqual(plan?["id"] as? String, "plan123") - } - - func testPresentationResultWithEmptyPlan() { - let resultMap: [String: Any] = [ - "result": 1, - "plan": [String: Any](), - ] - - XCTAssertEqual(resultMap["result"] as? Int, 1) - - let plan = resultMap["plan"] as? [String: Any] - XCTAssertNotNil(plan) - XCTAssertTrue(plan?.isEmpty ?? false) - } - - func testEventResultStructure() { - let eventResult: [String: Any] = [ - "name": "PRODUCT_PAGE_VIEWED", - "properties": ["productId": "prod123"], - ] - - XCTAssertEqual(eventResult["name"] as? String, "PRODUCT_PAGE_VIEWED") - - let properties = eventResult["properties"] as? [String: Any] - XCTAssertNotNil(properties) - XCTAssertEqual(properties?["productId"] as? String, "prod123") - } - - func testUserAttributeEventSetStructure() { - let event: [String: Any] = [ - "event": "set", - "key": "user_name", - "type": "STRING", - "value": "John", - "source": "flutter", - ] - - XCTAssertEqual(event["event"] as? String, "set") - XCTAssertEqual(event["key"] as? String, "user_name") - XCTAssertEqual(event["type"] as? String, "STRING") - XCTAssertEqual(event["value"] as? String, "John") - } - - func testUserAttributeEventRemovedStructure() { - let event: [String: Any] = [ - "event": "removed", - "key": "user_name", - "source": "flutter", - ] - - XCTAssertEqual(event["event"] as? String, "removed") - XCTAssertEqual(event["key"] as? String, "user_name") - } - - func testPaywallActionInterceptorResultStructure() { - let result: [String: Any] = [ - "action": "purchase", - "info": ["planId": "plan123"], - "parameters": ["discount": 10], - ] - - XCTAssertEqual(result["action"] as? String, "purchase") - let info = result["info"] as? [String: Any] - XCTAssertNotNil(info) - - let parameters = result["parameters"] as? [String: Any] - XCTAssertNotNil(parameters) - } - - func testDynamicOfferingResultStructure() { - let offering: [String: String] = [ - "reference": "ref123", - "planVendorId": "plan456", - "offerVendorId": "offer789", - ] - - XCTAssertEqual(offering["reference"], "ref123") - XCTAssertEqual(offering["planVendorId"], "plan456") - XCTAssertEqual(offering["offerVendorId"], "offer789") - } - - func testDynamicOfferingsListStructure() { - let offerings: [[String: String]] = [ - ["reference": "ref1", "planVendorId": "plan1"], - ["reference": "ref2", "planVendorId": "plan2", "offerVendorId": "offer2"], - ] - - XCTAssertEqual(offerings.count, 2) - XCTAssertEqual(offerings[0]["reference"], "ref1") - XCTAssertEqual(offerings[1]["offerVendorId"], "offer2") - } -} - -// MARK: - UIViewController Extension Tests - -class UIViewControllerExtensionTests: XCTestCase { - - func testCloseMethodExists() { - let viewController = UIViewController() - - // Test that the close method is accessible - XCTAssertTrue(viewController.responds(to: #selector(UIViewController.close))) - } -} - -// MARK: - NativeView Tests - -class NativeViewTests: XCTestCase { - - func testNativeViewReturnsContainerView() { - // NativeView should return a NativeContainerView (a UIView subclass) - // rather than the controller's view directly - let frame = CGRect(x: 0, y: 0, width: 320, height: 568) - // We can test that the container view approach works by creating one directly - let containerView = UIView(frame: frame) - let childView = UIView(frame: containerView.bounds) - childView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - containerView.addSubview(childView) - - XCTAssertEqual(containerView.subviews.count, 1) - XCTAssertEqual(childView.frame, containerView.bounds) - } - - func testAutoresizingMaskPropagatesFrameChanges() { - let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 568)) - let childView = UIView(frame: containerView.bounds) - childView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - containerView.addSubview(childView) - - // Simulate rotation by changing the container's frame - containerView.frame = CGRect(x: 0, y: 0, width: 568, height: 320) - containerView.layoutIfNeeded() - - // With autoresizing mask, child should adapt - XCTAssertEqual(childView.frame.width, 568, accuracy: 1) - XCTAssertEqual(childView.frame.height, 320, accuracy: 1) - } - - func testContainerViewLayoutSubviewsUpdatesChildren() { - // Test that frame changes on container propagate to children - let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 375, height: 812)) - let childView = UIView(frame: containerView.bounds) - childView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - containerView.addSubview(childView) - - // Simulate portrait -> landscape - containerView.frame = CGRect(x: 0, y: 0, width: 812, height: 375) - containerView.setNeedsLayout() - containerView.layoutIfNeeded() - - XCTAssertEqual(childView.frame.width, 812, accuracy: 1) - XCTAssertEqual(childView.frame.height, 375, accuracy: 1) - - // Simulate landscape -> portrait - containerView.frame = CGRect(x: 0, y: 0, width: 375, height: 812) - containerView.setNeedsLayout() - containerView.layoutIfNeeded() - - XCTAssertEqual(childView.frame.width, 375, accuracy: 1) - XCTAssertEqual(childView.frame.height, 812, accuracy: 1) - } - - func testViewControllerContainment() { - let parentVC = UIViewController() - let childVC = UIViewController() - - parentVC.addChild(childVC) - childVC.didMove(toParent: parentVC) - - XCTAssertEqual(parentVC.children.count, 1) - XCTAssertEqual(childVC.parent, parentVC) - - // Proper cleanup - childVC.willMove(toParent: nil) - childVC.view.removeFromSuperview() - childVC.removeFromParent() - - XCTAssertEqual(parentVC.children.count, 0) - XCTAssertNil(childVC.parent) - } - - func testViewControllerReceivesTransitionAfterContainment() { - let parentVC = UIViewController() - let childVC = UIViewController() - childVC.view.frame = CGRect(x: 0, y: 0, width: 320, height: 568) - - parentVC.addChild(childVC) - childVC.didMove(toParent: parentVC) + func testCloseReasonWireStringsMatchAndroid() { + // The bridge forwards `closeReason.rawDescription` to Dart; these must + // stay aligned with Android's PLYCloseReason.value wire strings. + XCTAssertEqual(PLYCloseReason.none.rawDescription, "none") + XCTAssertEqual(PLYCloseReason.button.rawDescription, "button") + XCTAssertEqual(PLYCloseReason.interactiveDismiss.rawDescription, "back_system") + XCTAssertEqual(PLYCloseReason.programmatic.rawDescription, "programmatic") + } - // viewWillTransition should not crash when called on a contained VC - let newSize = CGSize(width: 568, height: 320) - // This verifies the method exists and can be called - XCTAssertTrue(childVC.responds(to: #selector(UIViewController.viewWillTransition(to:with:)))) - } + // MARK: - Interceptor enums (v6) - func testOrientationNotificationRegistration() { - // Verify that UIDevice.orientationDidChangeNotification exists and is valid - let notificationName = UIDevice.orientationDidChangeNotification - XCTAssertEqual(notificationName.rawValue, "UIDeviceOrientationDidChangeNotification") - } -} + func testInterceptResultCases() { + let all: [PLYInterceptResult] = [.success, .failed, .notHandled] + XCTAssertEqual(Set(all).count, 3) + } -// MARK: - Test Helpers + func testInterceptActionCasesExist() { + // The bridge's `actionFromWire(_:)` maps these to the wire strings. + let actions: [PLYPresentationAction] = [ + .close, .closeAll, .login, .navigate, .purchase, + .restore, .openPresentation, .openPlacement, .promoCode, .webCheckout, + ] + XCTAssertEqual(actions.count, 10) + } -extension XCTestCase { + // MARK: - Display modes (v6) - /// Helper to wait for async operations - func waitForAsync(timeout: TimeInterval = 2.0) { - let expectation = XCTestExpectation(description: "Wait for async") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() + func testDisplayModeFactories() { + // Mirrors `parseTransition(_:)`: full screen, modal and the + // dimension-based drawer/popin. + XCTAssertNotNil(PLYDisplayMode.fullScreen) + XCTAssertNotNil(PLYDisplayMode.modal) + XCTAssertNotNil(PLYDisplayMode.drawer(height: .percentage(0.5), dismissible: true)) + XCTAssertNotNil(PLYDisplayMode.popin(width: nil, height: .percentage(0.5), dismissible: true)) } - wait(for: [expectation], timeout: timeout) - } } diff --git a/purchasely/example/lib/main.dart b/purchasely/example/lib/main.dart index ab135c2a..a1f3d0c6 100644 --- a/purchasely/example/lib/main.dart +++ b/purchasely/example/lib/main.dart @@ -1,12 +1,13 @@ +// ignore_for_file: avoid_print, library_private_types_in_public_api + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'dart:async'; import 'dart:developer'; -import 'dart:io'; import 'package:purchasely_flutter/purchasely_flutter.dart'; import 'presentation_screen.dart'; +import 'presentation_demo_screen.dart'; void main() { runApp(const MyApp()); @@ -31,42 +32,25 @@ class _MyAppState extends State { // Platform messages are asynchronous, so we initialize in an async method. Future initPurchaselySdk() async { try { - Purchasely.readyToOpenDeeplink(true); - /*Purchasely.listenToEvents((event) { print('Flutter Event : ${event.name}'); print('Event properties : ${event.properties.event_name}'); - print( - 'Event property displayed_options: ${event.properties.displayed_options}'); - print( - 'Event property selected_option_id: ${event.properties.selected_option_id}'); - print( - 'Event property selected_options: ${event.properties.selected_options}'); inspect(event); });*/ - bool configured = await Purchasely.start( - apiKey: 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d', - androidStores: ['Google'], - storeKit1: true, - logLevel: PLYLogLevel.debug); - - // Default values - /*bool configured = await Purchasely.start( - apiKey: 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d', - androidStores: ['Google'], - storeKit1: false, - logLevel: PLYLogLevel.error, - runningMode: PLYRunningMode.full, - userId: null, - );*/ + bool configured = + await Purchasely.apiKey('fcb39be4-2ba4-4db7-bde3-2a5a1e20745d') + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .stores([PLYStore.google]).start(); if (!configured) { print('Purchasely SDK not configured'); return; } - Purchasely.readyToOpenDeeplink(true); + Purchasely.allowDeeplink(true); Purchasely.setLogLevel(PLYLogLevel.debug); Purchasely.setUserAttributeListener(MyUserAttributeListener()); @@ -157,22 +141,6 @@ class _MyAppState extends State { print('Product found'); inspect(product); - /*Purchasely.setDefaultPresentationResultCallback( - (PresentPresentationResult value) { - print('Default Presentation Result Callback'); - //print('Presentation Result : ' + value.result.toString()); - - if (value.plan != null) { - //User bought a plan - } - });*/ - - Purchasely.setDefaultPresentationResultCallback( - (PresentPresentationResult result) { - print('Received result from screen'); - inspect(result); - }); - Purchasely.revokeDataProcessingConsent( [PLYDataProcessingPurpose.campaigns]); @@ -224,47 +192,31 @@ class _MyAppState extends State { Purchasely.setDebugMode(true); } - Purchasely.setPaywallActionInterceptorCallback( - (PaywallActionInterceptorResult result) { - print('Received action from paywall'); - inspect(result); - - if (result.action == PLYPaywallAction.navigate) { - print('User wants to navigate'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.close) { - print( - 'User wants to close paywall - reason: ${result.parameters.closeReason}"'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.login) { - print('User wants to login'); - //Present your own screen for user to log in - Purchasely.closePresentation(); - Purchasely.userLogin('MY_USER_ID'); - //Call this method to update Purchasely Paywall - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.open_presentation) { - print('User wants to open a new paywall'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.purchase) { - print('User wants to purchase'); - //If you want to intercept it, hide paywall and display your screen - Purchasely.hidePresentation(); - } else if (result.action == PLYPaywallAction.restore) { - print('User wants to restore his purchases'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.web_checkout) { - print('User wants to open web checkout'); - print( - 'webCheckoutProvider: ${result.parameters.webCheckoutProvider}'); - print('queryParameterKey: ${result.parameters.queryParameterKey}'); - print('clientReferenceId: ${result.parameters.clientReferenceId}'); - Purchasely.onProcessAction(true); - } else { - print('Action unknown ' + result.action.toString()); - Purchasely.onProcessAction(true); - } - }); + // Register a typed `navigate` action interceptor as an example. + await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (info, payload) { + if (payload is PLYNavigatePayload) { + print('User wants to navigate to ${payload.url}'); + } + return PLYInterceptResult.notHandled; + }, + ); + + // Register a typed `purchase` action interceptor: inspect the selected + // plan via the typed `PLYPurchasePayload`, then return `notHandled` so the + // SDK keeps owning the purchase flow. + await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) { + if (payload is PLYPurchasePayload) { + final planId = payload.plan.vendorId ?? payload.plan.productId; + print('User wants to purchase plan $planId — letting the SDK ' + 'proceed'); + } + return PLYInterceptResult.notHandled; + }, + ); } catch (e) { print(e); } @@ -323,116 +275,25 @@ class _MyAppState extends State { Future displayPresentation() async { try { - var result = await Purchasely.presentPresentationForPlacement("STRIPE", - isFullscreen: true); + final presentation = + await PLYPresentationBuilder.placement('FLOW').build().preload(); - switch (result.result) { - case PLYPurchaseResult.cancelled: - { - print("User cancelled purchased"); - } - break; - case PLYPurchaseResult.purchased: - { - print("User purchased ${result.plan?.name}"); - } - break; - case PLYPurchaseResult.restored: - { - print("User restored ${result.plan?.name}"); - } - break; - } - } catch (e) { - print(e); - } - } - - Future displayPresentationNativeView(BuildContext context) async { - // You can fetch the presentation before displaying it when ready - var presentation = await Purchasely.fetchPresentation("Settings"); - - if (presentation != null) { - navigatorKey.currentState?.push( - MaterialPageRoute( - builder: (context) => PresentationScreen( - properties: { - 'presentation': presentation, - //'contentId': null, // Optional - }, - callback: (PresentPresentationResult result) { - print('Presentation was closed'); - print( - 'Presentation result:${result.result} - plan:${result.plan?.vendorId}'); - navigatorKey.currentState?.pop(); - })), - ); - } else { - print("No presentation found"); - - // You can also display a presentation without fetching it before - // Purchasely will fetch it automatically, display a loader and display it - navigatorKey.currentState?.push( - MaterialPageRoute( - builder: (context) => PresentationScreen( - properties: const { - 'placementId': 'onboarding', - //'presentationId': 'TF1', // You can also set a presentationId directly but this is not recommended - //'contentId': null, // Optional - }, - callback: (PresentPresentationResult result) { - print('Presentation was closed'); - print( - 'Presentation result:${result.result} - plan:${result.plan?.vendorId}'); - navigatorKey.currentState?.pop(); - })), - ); - } - } - - Future fetchPresentation() async { - try { - var presentation = await Purchasely.fetchPresentation("FLOW"); - - if (presentation == null) { - print("No presentation found"); - return; - } + final outcome = await presentation.display(); - print("Presentation: ${presentation}"); + //.display(const PLYTransition.drawer(height: PLYTransitionDimension.percentage(0.5))); - if (presentation.type == PLYPresentationType.deactivated) { - // No paywall to display - return; - } - - if (presentation.type == PLYPresentationType.client) { - print("Presentation metadata: ${presentation.metadata}"); - return; - } - - //Display Purchasely paywall - var presentResult = await Purchasely.presentPresentation(presentation, - isFullscreen: true); - - print("-------"); - print("Presentation closed with result: ${presentResult.result}"); - - switch (presentResult.result) { + switch (outcome.purchaseResult) { case PLYPurchaseResult.cancelled: - { - print("User cancelled purchased"); - } + print("User cancelled purchase"); break; case PLYPurchaseResult.purchased: - { - print("User purchased ${presentResult.plan?.name}"); - } + print("User purchased ${outcome.plan}"); break; case PLYPurchaseResult.restored: - { - print("User restored ${presentResult.plan?.name}"); - } + print("User restored ${outcome.plan}"); + break; + case null: + print("PLYPresentation dismissed without a purchase"); break; } } catch (e) { @@ -440,17 +301,36 @@ class _MyAppState extends State { } } - Future displaySubscriptions() async { - try { - Purchasely.presentSubscriptions(); - } catch (e) { - print(e); + Future displayPresentationInline(BuildContext context) async { + // Closing an inline paywall fires BOTH onCloseRequested (the ✕ asks the + // host to close) and, right after the view is removed, onDismissed. Pop the + // route only ONCE — otherwise the second pop would also dismiss the screen + // underneath (black screen). + var popped = false; + void closeInline() { + if (popped) return; + popped = true; + navigatorKey.currentState?.pop(); } - } - Future continuePurchase() async { - Purchasely.showPresentation(); - Purchasely.onProcessAction(true); + navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (context) => PresentationScreen.placement( + 'promo_offers', + onCloseRequested: () { + // Inline view: the ✕ button only requests a close, so we pop the + // screen ourselves. + print('PLYPresentation close requested — popping inline screen'); + closeInline(); + }, + onDismissed: (outcome) { + print('PLYPresentation was closed'); + print('PLYPresentation result: ${outcome.purchaseResult}'); + closeInline(); + }, + ), + ), + ); } Future purchase() async { @@ -499,26 +379,15 @@ class _MyAppState extends State { } Future synchronize() async { - Purchasely.synchronize(); - print('synchronization with Purchasely'); - } - - Future hidePresentation() async { - Purchasely.hidePresentation(); - } - - Future showPresentation() async { - Purchasely.showPresentation(); - } - - Future closePresentation() async { - Purchasely.closePresentation(); - } - - Future testFunction() async { - displayPresentation(); - sleep(const Duration(seconds: 3)); - displayPresentation(); + // Since the 6.0 native SDKs expose success/error callbacks on + // synchronize(), the Dart Future now resolves once the sync completes and + // throws on failure — so it can be awaited and wrapped in try/catch. + try { + await Purchasely.synchronize(); + print('synchronization with Purchasely succeeded'); + } catch (e) { + print('synchronization with Purchasely failed: $e'); + } } @override @@ -533,59 +402,40 @@ class _MyAppState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + // PLYPresentation API demo — start, display, interceptor, enriched outcome. ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.only(left: 20.0, right: 30.0), + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, ), onPressed: () { - displayPresentation(); - }, - child: const Text('Display presentation'), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.only(left: 20.0, right: 30.0), - ), - onPressed: () { - displayPresentationNativeView(context); + final navigator = navigatorKey.currentState; + navigator?.push( + MaterialPageRoute( + builder: (_) => const PresentationDemoScreen(), + ), + ); }, - child: const Text('Display presentation (Native View)'), + child: const Text('Open presentation demo'), ), ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.only(left: 20.0, right: 30.0), ), onPressed: () { - fetchPresentation(); - }, - child: const Text('Fetch presentation'), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.only(left: 20.0, right: 30.0), - ), - onPressed: () { - showPresentation(); - }, - child: const Text('Show presentation'), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.only(left: 20.0, right: 30.0), - ), - onPressed: () { - closePresentation(); + displayPresentation(); }, - child: const Text('Close presentation'), + child: const Text('Display presentation'), ), ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.only(left: 20.0, right: 30.0), ), onPressed: () { - continuePurchase(); + displayPresentationInline(context); }, - child: const Text('Continue purchase'), + child: const Text('Display presentation (Inline View)'), ), ElevatedButton( style: ElevatedButton.styleFrom( @@ -614,15 +464,6 @@ class _MyAppState extends State { }, child: const Text('Sign promotional offer'), ), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.only(left: 20.0, right: 30.0), - ), - onPressed: () { - displaySubscriptions(); - }, - child: const Text('Display subscriptions'), - ), ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.only(left: 20.0, right: 30.0), diff --git a/purchasely/example/lib/presentation_demo_screen.dart b/purchasely/example/lib/presentation_demo_screen.dart new file mode 100644 index 00000000..d9587e90 --- /dev/null +++ b/purchasely/example/lib/presentation_demo_screen.dart @@ -0,0 +1,200 @@ +// Demo screen for the Purchasely Flutter presentation API. +// +// Shows the canonical flow: +// 1. Initialise the SDK via `Purchasely.apiKey(...).start()`. +// 2. Build a presentation request via `PLYPresentationBuilder.placement(...)`. +// 3. Display it and surface the enriched 5-field `PLYPresentationOutcome` +// (presentation, purchaseResult, plan, closeReason, error). +// +// Interceptor registration is exposed via the `Register interceptors` button — +// see `registerInterceptors()` below. It uses the clean public API +// `Purchasely.interceptAction(kind, handler)` and demonstrates two kinds: +// - a `navigate` interceptor that logs the outbound URL, and +// - a `purchase` interceptor that inspects the typed `PLYPurchasePayload` +// (the selected plan) and returns `PLYInterceptResult.notHandled` so the +// SDK keeps owning the purchase flow. + +import 'package:flutter/material.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +class PresentationDemoScreen extends StatefulWidget { + const PresentationDemoScreen({Key? key}) : super(key: key); + + @override + State createState() => _PresentationDemoScreenState(); +} + +class _PresentationDemoScreenState extends State { + String _status = 'Tap "Start SDK" to begin.'; + PLYPresentationOutcome? _lastOutcome; + PLYPresentationError? _lastError; + + Future _startSdk() async { + setState(() => _status = 'Starting…'); + try { + final ok = await Purchasely.apiKey( + 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d', + ) + .runningMode(PLYRunningMode.observer) + .logLevel(PLYLogLevel.debug) + .stores([PLYStore.google]).start(); + setState(() => _status = 'Started: $ok'); + } catch (e) { + setState(() => _status = 'Start failed: $e'); + } + } + + Future _displayPresentation() async { + setState(() { + _status = 'Displaying…'; + _lastOutcome = null; + _lastError = null; + }); + + try { + final outcome = await PLYPresentationBuilder.placement('onboarding') + .contentId('demo-content-42') + .onLoaded((presentation, error) { + debugPrint( + 'onLoaded — screenId=${presentation.screenId} error=$error'); + }) + .onPresented((presentation, error) { + debugPrint('onPresented — error=$error'); + }) + .onCloseRequested(() { + debugPrint('onCloseRequested'); + }) + .onDismissed((o) { + debugPrint('onDismissed — outcome=$o'); + }) + .build() + .preload() + .display(const PLYTransition.drawer( + height: PLYTransitionDimension.percentage(0.5))); + + setState(() { + _lastOutcome = outcome; + _status = 'Dismissed.'; + }); + } on PLYPresentationError catch (e) { + setState(() { + _lastError = e; + _status = 'Display failed.'; + }); + } catch (e) { + setState(() => _status = 'Display crashed: $e'); + } + } + + /// Register two typed action interceptors via the public + /// `Purchasely.interceptAction(kind, handler)` API: + /// + /// * `navigate` — logs the outbound URL from the typed [PLYNavigatePayload]. + /// * `purchase` — inspects the typed [PLYPurchasePayload] (the selected + /// plan) and returns [PLYInterceptResult.notHandled] so the SDK proceeds + /// with its own purchase flow. + /// + /// Both handlers downcast the generic [PLYActionPayload] to the concrete + /// payload type, showing the typed-payload pattern. + Future _registerInterceptors() async { + await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (info, payload) { + if (payload is PLYNavigatePayload) { + debugPrint('Intercepted navigate to ${payload.url}'); + } + return PLYInterceptResult.notHandled; + }, + ); + + await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) { + if (payload is PLYPurchasePayload) { + // The typed payload exposes the selected plan (and any offer). + final planId = payload.plan.vendorId ?? payload.plan.productId; + debugPrint('Intercepted purchase of plan $planId — letting the SDK ' + 'proceed (notHandled)'); + } + // Return notHandled so the SDK keeps owning the purchase flow. + return PLYInterceptResult.notHandled; + }, + ); + + setState(() => _status = 'Navigate + purchase interceptors registered'); + } + + Widget _outcomeCard(PLYPresentationOutcome outcome) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Outcome (5 fields)', + style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Text('presentation.screenId: ${outcome.presentation?.screenId}'), + Text('purchaseResult: ${outcome.purchaseResult}'), + Text('plan: ${outcome.plan?.vendorId}'), + Text('closeReason: ${outcome.closeReason}'), + Text('error: ${outcome.error}'), + ], + ), + ), + ); + } + + Widget _errorCard(PLYPresentationError error) { + return Card( + color: Colors.red.shade50, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('PLYPresentationError', + style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Text('code: ${error.code}'), + Text('message: ${error.message}'), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Purchasely presentation demo')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton( + onPressed: _startSdk, child: const Text('Start SDK')), + ElevatedButton( + onPressed: _displayPresentation, + child: const Text('Display presentation')), + ElevatedButton( + onPressed: _registerInterceptors, + child: const Text('Register interceptors')), + ], + ), + const SizedBox(height: 16), + Text(_status, style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 16), + if (_lastOutcome != null) _outcomeCard(_lastOutcome!), + if (_lastError != null) _errorCard(_lastError!), + ], + ), + ), + ); + } +} diff --git a/purchasely/example/lib/presentation_screen.dart b/purchasely/example/lib/presentation_screen.dart index 26a0bf99..47e808e8 100644 --- a/purchasely/example/lib/presentation_screen.dart +++ b/purchasely/example/lib/presentation_screen.dart @@ -1,78 +1,100 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; import 'package:purchasely_flutter/native_view_widget.dart'; import 'package:purchasely_flutter/purchasely_flutter.dart'; +/// Renders a Purchasely presentation inline (embedded) inside a screen. +/// +/// Build the [PLYPresentationRequest] with the fluent [PLYPresentationBuilder] and +/// pass it in. The [PLYPresentationView] preloads it and hands the resulting +/// `requestId` to the native inline view. +/// +/// Inline vs modal: a modal/drawer presentation dismisses itself when the user +/// taps the close (✕) button. An **inline** view does not — tapping ✕ only +/// emits [PLYPresentationRequest.onCloseRequested]. It is up to the host to +/// react (here: pop this screen). Without wiring `onCloseRequested`, the close +/// button appears to do nothing. class PresentationScreen extends StatelessWidget { - final Map properties; - final Function(PresentPresentationResult)? callback; + final PLYPresentationRequest request; + + const PresentationScreen({Key? key, required this.request}) : super(key: key); - PresentationScreen({required this.properties, this.callback}); + /// Convenience constructor that builds a [PLYPresentationRequest] for a + /// placement, wiring the close + dismiss callbacks to pop the screen. + factory PresentationScreen.placement( + String placementId, { + Key? key, + String? contentId, + void Function()? onCloseRequested, + void Function(PLYPresentationOutcome outcome)? onDismissed, + }) { + final request = PLYPresentationBuilder.placement(placementId) + .contentId(contentId) + .onPresented((presentation, error) { + debugPrint('PLYPresentation presented — error=$error'); + }).onCloseRequested(() { + // Inline views don't auto-dismiss: the ✕ button only fires this event, + // so the host must close the view itself. + debugPrint('PLYPresentation close requested (inline)'); + onCloseRequested?.call(); + }).onDismissed((outcome) { + debugPrint( + 'PLYPresentation dismissed — purchaseResult=${outcome.purchaseResult}'); + onDismissed?.call(outcome); + }).build(); + return PresentationScreen(key: key, request: request); + } @override Widget build(BuildContext context) { return SafeArea( - // Wrap with SafeArea child: Scaffold( body: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ + // ── Contenu AU-DESSUS du PLYPresentationView ────────────────── + Container( + width: double.infinity, + color: Colors.indigo, + padding: const EdgeInsets.all(16), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contenu au-dessus', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 4), + Text( + 'Le paywall ci-dessous est rendu en mode inline (embarqué).', + style: TextStyle(color: Colors.white70), + ), + ], + ), + ), + + // ── Le paywall inline ───────────────────────────────────────── Expanded( - child: _buildPresentationView(), - ) + child: PLYPresentationView(request: request), + ), + + // ── Contenu EN-DESSOUS du PLYPresentationView ───────────────── + Container( + width: double.infinity, + color: Colors.indigo.shade50, + padding: const EdgeInsets.all(16), + child: const Text( + 'Contenu en-dessous — appuyez sur la croix (✕) du paywall ' + 'pour fermer cet écran.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.indigo), + ), + ), ], ), ), ); } - - Widget _buildPresentationView() { - // You can set a paywall action interceptor if you want to handle the close differently, - // handle login or make the purchase yourself - Purchasely.setPaywallActionInterceptorCallback( - (PaywallActionInterceptorResult result) { - print('Received action from paywall'); - inspect(result); - - if (result.action == PLYPaywallAction.navigate) { - print('User wants to navigate'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.close) { - print( - 'User wants to close paywall - reason: ${result.parameters.closeReason}"'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.login) { - print('User wants to login'); - //Present your own screen for user to log in - Purchasely.userLogin('MY_USER_ID'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.open_presentation) { - print('User wants to open a new paywall'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.purchase) { - print('User wants to purchase'); - Purchasely.onProcessAction(true); - } else if (result.action == PLYPaywallAction.restore) { - print('User wants to restore his purchases'); - Purchasely.onProcessAction(true); - } else { - print('Action unknown ' + result.action.toString()); - Purchasely.onProcessAction(true); - } - }); - - PLYPresentationView? presentationView = Purchasely.getPresentationView( - presentation: properties['presentation'], - presentationId: properties['presentationId'], - placementId: properties['placementId'], - contentId: properties['contentId'], - callback: callback ?? - (PresentPresentationResult result) { - print( - 'Presentation result:${result.result} - plan:${result.plan?.vendorId}'); - }); - - return presentationView ?? Container(); - } } diff --git a/purchasely/example/pubspec.lock b/purchasely/example/pubspec.lock index 863b6d09..57e7ecb5 100644 --- a/purchasely/example/pubspec.lock +++ b/purchasely/example/pubspec.lock @@ -1,14 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.1.2" collection: dependency: transitive description: @@ -25,11 +49,32 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -38,6 +83,45 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" lints: dependency: transitive description: @@ -46,42 +130,146 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "5.0.5" purchasely_flutter: dependency: "direct main" description: path: ".." relative: true source: path - version: "5.7.1" + version: "6.0.0-rc.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=1.20.0" + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/purchasely/example/pubspec.yaml b/purchasely/example/pubspec.yaml index ca56c27b..0d9b6ed9 100644 --- a/purchasely/example/pubspec.yaml +++ b/purchasely/example/pubspec.yaml @@ -33,8 +33,10 @@ dependencies: cupertino_icons: ^1.0.8 dev_dependencies: - #flutter_test: - #sdk: flutter + flutter_test: + sdk: flutter + integration_test: + sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/purchasely/ios/Classes/NativeView.swift b/purchasely/ios/Classes/NativeView.swift index 6f7dc497..5320d953 100644 --- a/purchasely/ios/Classes/NativeView.swift +++ b/purchasely/ios/Classes/NativeView.swift @@ -7,17 +7,36 @@ import Purchasely class NativeView: NSObject, FlutterPlatformView { private var _containerView: NativeContainerView private var _controller: UIViewController? + private let _requestId: String? + // Guards against double-emitting onDismissed (the loaded presentation's + // onDismissed callback and the `.presentationClosed` event can both fire). + private var _didEmitDismissed = false init( frame: CGRect, viewIdentifier viewId: Int64, - arguments args: Any?, - channel: FlutterMethodChannel + arguments args: Any? ) { _containerView = NativeContainerView(frame: frame) + _requestId = (args as? [String: Any])?["requestId"] as? String super.init() Purchasely.setEventDelegate(self) - self._controller = SwiftPurchaselyFlutterPlugin.getPresentationController(for: args, with: channel) + + // The inline native view is built from a Presentation that was already + // loaded (via `preload`) and is keyed by the Dart requestId. + // Creation-param contract: `{ "requestId": }`. + self._controller = SwiftPurchaselyFlutterPlugin.presentationController(for: args) + + // Surface the embedded outcome through the SAME presentation-events sink + // and envelope shape as the full-screen path, keyed by the request's + // `requestId`, so the Dart `onDismissed` callback (and the pending + // `display()` future) fire for the inline path too. + if let requestId = _requestId, + let presentation = SwiftPurchaselyFlutterPlugin.loadedPresentations[requestId] { + presentation.onDismissed = { [weak self] outcome in + self?.emitDismissed(requestId: requestId, outcome: outcome) + } + } if let controller = _controller { let childView = controller.view! @@ -79,6 +98,23 @@ class NativeView: NSObject, FlutterPlatformView { return UIApplication.shared.delegate?.window??.rootViewController } + /// Emits the `onDismissed` envelope once, mirroring the full-screen path, + /// and clears the request's static state so a re-display re-registers. + private func emitDismissed(requestId: String, outcome: PLYPresentationOutcome) { + guard !_didEmitDismissed else { return } + _didEmitDismissed = true + let presentation = SwiftPurchaselyFlutterPlugin.loadedPresentations[requestId] + SwiftPurchaselyFlutterPlugin.emitPresentationEvent([ + "event": "onDismissed", + "requestId": requestId, + "outcome": SwiftPurchaselyFlutterPlugin.outcomeMap( + outcome, presentation: presentation, error: nil, requestId: requestId + ) as Any?, + ]) + SwiftPurchaselyFlutterPlugin.loadedPresentations.removeValue(forKey: requestId) + SwiftPurchaselyFlutterPlugin.requests.removeValue(forKey: requestId) + } + private func cleanupController() { guard let controller = _controller else { return } if controller.parent != nil { @@ -170,7 +206,17 @@ extension NativeView: PLYEventDelegate { func eventTriggered(_ event: PLYEvent, properties: [String : Any]?) { if event == .presentationClosed { DispatchQueue.main.async { [weak self] in - self?.cleanupController() + guard let self = self else { return } + // Fallback: if the loaded presentation's `onDismissed` callback did + // not fire for the embedded controller, synthesise the dismissal so + // the Dart `onDismissed` still resolves. Idempotent via the guard. + if let requestId = self._requestId, !self._didEmitDismissed { + self.emitDismissed( + requestId: requestId, + outcome: PLYPresentationOutcome() + ) + } + self.cleanupController() } } } diff --git a/purchasely/ios/Classes/NativeViewFactory.swift b/purchasely/ios/Classes/NativeViewFactory.swift index eef48378..61003481 100644 --- a/purchasely/ios/Classes/NativeViewFactory.swift +++ b/purchasely/ios/Classes/NativeViewFactory.swift @@ -5,14 +5,9 @@ import Purchasely class NativeViewFactory: NSObject, FlutterPlatformViewFactory { private var messenger: FlutterBinaryMessenger - private var channel: FlutterMethodChannel - - let CHANNEL_ID = "native_view_channel" init(messenger: FlutterBinaryMessenger) { self.messenger = messenger - self.channel = FlutterMethodChannel(name: CHANNEL_ID, - binaryMessenger: messenger) super.init() } @@ -21,12 +16,13 @@ class NativeViewFactory: NSObject, FlutterPlatformViewFactory { viewIdentifier viewId: Int64, arguments args: Any? ) -> FlutterPlatformView { - + // The inline view surfaces its outcome through the plugin's shared + // `purchasely-presentation-events` sink (see NativeView), not a dedicated + // MethodChannel, so no per-view channel is needed. return NativeView( frame: frame, viewIdentifier: viewId, - arguments: args, - channel: channel) + arguments: args) } /// Implementing this method is only necessary when the `arguments` in `createWithFrame` is not `nil`. diff --git a/purchasely/ios/Classes/PLYPresentation+ToMap.swift b/purchasely/ios/Classes/PLYPresentation+ToMap.swift deleted file mode 100644 index 511eee09..00000000 --- a/purchasely/ios/Classes/PLYPresentation+ToMap.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// PLYPresentation+ToMap.swift -// purchasely_flutter -// -// Created by Mathieu LANOY on 27/01/2023. -// - -import Foundation -import Purchasely - -extension PLYPresentation { - - var toMap: [String: Any] { - var result = [String: Any]() - - if let id = self.id { - result["id"] = id - } - - if let placementId = self.placementId { - result["placementId"] = placementId - } - - if let audienceId = self.audienceId { - result["audienceId"] = audienceId - } - - if let abTestId = self.abTestId { - result["abTestId"] = abTestId - } - - if let abTestVariantId = self.abTestVariantId { - result["abTestVariantId"] = abTestVariantId - } - - result["language"] = language - - result["height"] = height - - result["plans"] = self.plans.map({ - var newPresentationPlan: [String : Any?] = [:] - newPresentationPlan["offerId"] = $0.offerId - newPresentationPlan["storeProductId"] = $0.storeProductId - newPresentationPlan["planVendorId"] = $0.planVendorId - newPresentationPlan["basePlanId"] = nil - return newPresentationPlan - }) - - result["type"] = self.type.rawValue - - result["metadata"] = getPresentationMetadata(self.metadata) - - return result - } - - private func getPresentationMetadata(_ metadata: PLYPresentationMetadata?) -> [String : Any?] { - guard let metadata = metadata else { return [:] } - - let rawMetadata = metadata.getRawMetadata() - var resultDict: [String: Any?] = [:] - let group = DispatchGroup() - let semaphore = DispatchSemaphore(value: 0) - - for (key, value) in rawMetadata { - if let _ = value as? String { - group.enter() // Enter the dispatch group before making the async call - - metadata.getString(with: key) { result in - resultDict[key] = result - group.leave() // Leave the dispatch group after the async call is completed - } - } else { - resultDict[key] = value - } - } - - group.notify(queue: DispatchQueue.global(qos: .default)) { - semaphore.signal() - } - - // Wait until all async calls are completed - semaphore.wait() - - return resultDict - } -} diff --git a/purchasely/ios/Classes/PLYPresentationActionParameters+ToMap.swift b/purchasely/ios/Classes/PLYPresentationActionParameters+ToMap.swift deleted file mode 100644 index e26a21b9..00000000 --- a/purchasely/ios/Classes/PLYPresentationActionParameters+ToMap.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// PLYPresentationActionParameters+ToMap.swift -// purchasely_flutter -// -// Created by Mathieu LANOY on 13/01/2022. -// - -import Foundation -import Purchasely - -extension PLYPresentationActionParameters { - - var toMap: [String: Any] { - var result = [String: Any]() - - if let url = url?.absoluteString { - result["url"] = url - } - - if let plan = plan { - result["plan"] = plan.toMap - } - - if let title = title { - result["title"] = title - } - - if let presentation = presentation { - result["presentation"] = presentation - } - - if let promoOffer = promoOffer { - var offerMap = [String: Any]() - offerMap["vendorId"] = promoOffer.vendorId - offerMap["storeOfferId"] = promoOffer.storeOfferId - result["offer"] = offerMap - } - - if let queryParameterKey = queryParameterKey { - result["queryParameterKey"] = queryParameterKey - } - - if let clientReferenceId = clientReferenceId { - result["clientReferenceId"] = clientReferenceId - } - - let webCheckoutProviderString: String - switch webCheckoutProvider { - case .stripe: - webCheckoutProviderString = "stripe" - case .other: - webCheckoutProviderString = "other" - case .none: - webCheckoutProviderString = "none" - } - result["webCheckoutProvider"] = webCheckoutProviderString - - return result - } - -} diff --git a/purchasely/ios/Classes/PLYPresentationInfo+ToMap.swift b/purchasely/ios/Classes/PLYPresentationInfo+ToMap.swift deleted file mode 100644 index 27a1deba..00000000 --- a/purchasely/ios/Classes/PLYPresentationInfo+ToMap.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PLYPresentationInfo+ToMap.swift -// purchasely_flutter -// -// Created by Mathieu LANOY on 13/01/2022. -// - -import Foundation -import Purchasely - -extension PLYPresentationInfo { - - var toMap: [String: Any] { - var result = [String: Any]() - - if let contentId = contentId { - result["contentId"] = contentId - } - - if let presentationId = presentationId { - result["presentationId"] = presentationId - } - - if let placementId = placementId { - result["placementId"] = placementId - } - - if let abTestId = abTestId { - result["abTestId"] = abTestId - } - - if let abTestVariantId = abTestVariantId { - result["abTestVariantId"] = abTestVariantId - } - - return result - } - -} diff --git a/purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift b/purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift index 07de1fd0..758925be 100644 --- a/purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift +++ b/purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift @@ -4,11 +4,22 @@ import Purchasely public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { - private static var presentationsLoaded = [PLYPresentation]() - private static var purchaseResult: FlutterResult? - private static var isStarted: Bool = false + // Presentation/interceptor state shared with the inline NativeView. Keyed by + // the Dart-side `requestId` so close/back/display and the platform view can + // find the right handle. Retained after dismissal to support re-displaying a + // Dart Presentation handle; there is no native dispose API yet. + static var requests: [String: PLYPresentationRequest] = [:] + static var loadedPresentations: [String: PLYPresentation] = [:] + // invocationId -> SDK interceptor completion. Single-shot, removed on resolve. + private static var pendingInterceptors: [String: (PLYInterceptResult) -> Void] = [:] + + // The live plugin instance, so the inline NativeView can reach the shared + // `purchasely-presentation-events` sink and surface the same `onDismissed` + // envelope as the full-screen path. + private(set) static weak var shared: SwiftPurchaselyFlutterPlugin? + let eventChannel: FlutterEventChannel let eventHandler: SwiftEventHandler @@ -18,9 +29,13 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { let userAttributesChannel: FlutterEventChannel let userAttributesHandler: UserAttributesHandler - var presentedPresentationViewController: UIViewController? + // Presentation/interceptor lifecycle events flow over a dedicated stream, + // discriminated by the `event` key; each carries a `requestId` so Dart can + // route back. + let presentationChannel: FlutterEventChannel + let presentationEventHandler: PresentationEventHandler - var onProcessActionHandler: ((Bool) -> Void)? + var presentedPresentationViewController: UIViewController? public init(with registrar: FlutterPluginRegistrar) { self.eventChannel = FlutterEventChannel(name: "purchasely-events", @@ -38,7 +53,33 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { self.userAttributesHandler = UserAttributesHandler() self.userAttributesChannel.setStreamHandler(self.userAttributesHandler) + self.presentationChannel = FlutterEventChannel(name: "purchasely-presentation-events", + binaryMessenger: registrar.messenger()) + self.presentationEventHandler = PresentationEventHandler() + self.presentationChannel.setStreamHandler(self.presentationEventHandler) + super.init() + SwiftPurchaselyFlutterPlugin.shared = self + } + + /// Emits a presentation lifecycle envelope onto the shared + /// `purchasely-presentation-events` sink. Used by the inline NativeView so + /// the embedded path surfaces the same `{ event, requestId, outcome }` + /// envelopes as the full-screen path. + static func emitPresentationEvent(_ payload: [String: Any?]) { + shared?.presentationEventHandler.emit(payload) + } + + /// Static accessor to the outcome serializer so the inline NativeView emits + /// the exact same `outcome` shape as the full-screen path. + static func outcomeMap(_ outcome: PLYPresentationOutcome, + presentation: PLYPresentation?, + error: Error?, + requestId: String) -> [String: Any?] { + return shared?.outcomeToMap(outcome, + presentation: presentation, + error: error, + requestId: requestId) ?? [:] } public static func register(with registrar: FlutterPluginRegistrar) { @@ -55,30 +96,31 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { let arguments = call.arguments as? [String: Any] switch call.method { + // --- start --- case "start": - start(arguments: call.arguments as? [String: Any], result: result) + start(arguments: arguments, result: result) + + // --- presentation lifecycle --- + case "preload": + preload(arguments, result: result) + case "display": + display(arguments, result: result) case "close": - DispatchQueue.main.async { - result(true) - } - case "setDefaultPresentationResultHandler": - setDefaultPresentationResultHandler(result: result) - case "fetchPresentation": - fetchPresentation(arguments: arguments, result: result) - case "presentPresentation": - presentPresentation(arguments: arguments, result: result) - case "clientPresentationDisplayed": - clientPresentationDisplayed(arguments: arguments) - case "clientPresentationClosed": - clientPresentationClosed(arguments: arguments) - case "presentPresentationWithIdentifier": - presentPresentationWithIdentifier(arguments: arguments, result: result) - case "presentProductWithIdentifier": - presentProductWithIdentifier(arguments: arguments, result: result) - case "presentPlanWithIdentifier": - presentPlanWithIdentifier(arguments: arguments, result: result) - case "presentPresentationForPlacement": - presentPresentationForPlacement(arguments: arguments, result: result) + closePresentation(arguments, result: result) + case "back": + back(arguments, result: result) + + // --- action interceptor --- + case "registerInterceptor": + registerInterceptor(arguments, result: result) + case "removeInterceptor": + removeInterceptor(arguments, result: result) + case "removeAllInterceptors": + removeAllInterceptors(result: result) + case "interceptorResolve": + interceptorResolve(arguments, result: result) + + // --- kept v5 surface --- case "restoreAllProducts": restoreAllProducts(result) case "silentRestoreAllProducts": @@ -94,9 +136,16 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { userLogin(arguments: arguments, result: result) case "userLogout": userLogout(result: result) - case "readyToOpenDeeplink": - let parameter = arguments?["readyToOpenDeeplink"] as? Bool - readyToOpenDeeplink(readyToOpenDeeplink: parameter) + case "allowDeeplink": + let parameter = arguments?["allowDeeplink"] as? Bool + allowDeeplink(allowDeeplink: parameter) + result(true) + case "allowCampaigns": + let parameter = arguments?["allowCampaigns"] as? Bool + allowCampaigns(allowCampaigns: parameter) + result(true) + case "setDefaultPresentationDismissHandler": + setDefaultPresentationDismissHandler(result: result) case "setLogLevel": let parameter = (arguments?["logLevel"] as? Int) ?? PLYLogger.PLYLogLevel.debug.rawValue let logLevel = PLYLogger.PLYLogLevel(rawValue: parameter) ?? PLYLogger.PLYLogLevel.debug @@ -112,29 +161,25 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { allProducts(result) case "purchaseWithPlanVendorId": purchaseWithPlanVendorId(arguments: arguments, result: result) - case "isDeeplinkHandled": + case "handleDeeplink": let parameter = arguments?["deeplink"] as? String - isDeeplinkHandled(parameter, result: result) + handleDeeplink(parameter, result: result) case "userSubscriptions": userSubscriptions(result) case "userSubscriptionsHistory": userSubscriptionsHistory(result) - case "presentSubscriptions": - presentSubscriptions() case "setThemeMode": setThemeMode(arguments: arguments) + result(true) case "setAttribute": setAttribute(arguments: arguments) - case "setPaywallActionInterceptor": - setPaywallActionInterceptor(result: result) + result(true) case "setLanguage": let parameter = arguments?["language"] as? String setLanguage(with: parameter) - case "onProcessAction": - let parameter = arguments?["processAction"] as? Bool - onProcessAction(parameter ?? true) case "userDidConsumeSubscriptionContent": userDidConsumeSubscriptionContent() + result(true) case "setUserAttributeWithString": setUserAttributeWithString(arguments: arguments) case "setUserAttributeWithInt": @@ -168,15 +213,10 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { case "clearBuiltInAttributes": clearBuiltInAttributes() case "displaySubscriptionCancellationInstruction": - result(FlutterMethodNotImplemented) + // iOS has no dedicated cancellation-instruction screen; no-op. + result(true) case "isAnonymous": isAnonymous(result: result) - case "hidePresentation": - hidePresentation() - case "showPresentation": - showPresentation() - case "closePresentation": - closePresentation() case "signPromotionalOffer": signPromotionalOffer(arguments: arguments, result: result) case "isEligibleForIntroOffer": @@ -193,124 +233,17 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { revokeDataProcessingConsent(arguments: arguments) case "setDebugMode": setDebugMode(arguments: arguments) + result(true) default: result(FlutterMethodNotImplemented) } } - internal static func getPresentationController(for args: Any?, with channel: FlutterMethodChannel) -> UIViewController? { - - if let creationParams = args as? [String: Any] { - - let presentationId = creationParams["presentationId"] as? String - let placementId = creationParams["placementId"] as? String - - guard let presentationMap = creationParams["presentation"] as? [String:Any], - let mapPresentationId = presentationMap["id"] as? String, - let mapPlacementId = presentationMap["placementId"] as? String, - let presentationLoaded = presentationsLoaded.filter({ $0.id == mapPresentationId && $0.placementId == mapPlacementId }).first, - let presentationLoadedController = presentationLoaded.controller else { - return SwiftPurchaselyFlutterPlugin.createNativeViewController(presentationId: presentationId, placementId: placementId, channel: channel) - } - - SwiftPurchaselyFlutterPlugin.purchaseResult = { result in - if let value = result as? [String : Any] { - channel.invokeMethod("onPresentationResult", arguments: ["result": value["result"], - "plan": value["plan"]]) - } - } - return presentationLoadedController - } - return nil - } - - private static func createNativeViewController(presentationId: String?, - placementId: String?, - channel: FlutterMethodChannel?) -> UIViewController? { - if let presentationId = presentationId { - let controller = Purchasely.presentationController( - with: presentationId, - loaded: nil, - completion: { result, plan in - if let plan = plan { - channel?.invokeMethod("onPresentationResult", arguments: ["result": result.rawValue, - "plan": plan.toMap]) - } else { - channel?.invokeMethod("onPresentationResult", arguments: ["result": result.rawValue, - "plan": nil]) - } - } - ) - return controller - } - else if let placementId = placementId { - let controller = Purchasely.presentationController( - for: placementId, - loaded: nil, - completion: { result, plan in - if let plan = plan { - channel?.invokeMethod("onPresentationResult", arguments: ["result": result.rawValue, - "plan": plan.toMap]) - } else { - channel?.invokeMethod("onPresentationResult", arguments: ["result": result.rawValue, - "plan": nil]) - } - } - ) - return controller - } - return nil - } - - private func isAnonymous(result: @escaping FlutterResult) { - result(Purchasely.isAnonymous()) - } - - private func hidePresentation() { - if let presentedPresentationViewController = presentedPresentationViewController { - DispatchQueue.main.async { - var presentingViewController = presentedPresentationViewController; - while let presentingController = presentingViewController.presentingViewController { - presentingViewController = presentingController - } - presentingViewController.dismiss(animated: true, completion: nil) - } - } - } - - private func closePresentation() { - self.presentedPresentationViewController = nil - Purchasely.closeDisplayedPresentation() - } - - private func showPresentation() { - if let presentedPresentationViewController = presentedPresentationViewController { - DispatchQueue.main.async { - Purchasely.showController(presentedPresentationViewController, type: .productPage) - } - } - } - - private func isEligibleForIntroOffer(arguments: [String: Any]?, result: @escaping FlutterResult) { - guard let arguments = arguments, let planVendorId = arguments["planVendorId"] as? String else { - result(FlutterError.failedArgumentField("planVendorId", type: String.self)) - return - } - - DispatchQueue.main.async { - Purchasely.plan(with: planVendorId) { plan in - plan.isUserEligibleForIntroductoryOffer { res in - result(res) - } - } failure: { error in - result(FlutterError.error(code:"-1", message:"plan \(planVendorId) not found", error: error)) - } - } - } + // MARK: - start private func start(arguments: [String: Any]?, result: @escaping FlutterResult) { - guard let arguments = arguments, let apiKey = arguments["apiKey"] as? String else { + guard let arguments = arguments, let apiKey = arguments["apiKey"] as? String, !apiKey.isEmpty else { result(FlutterError.failedArgumentField("apiKey", type: String.self)) return } @@ -320,293 +253,486 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { return } - Purchasely.setSdkBridgeVersion("5.7.3") - Purchasely.setAppTechnology(PLYAppTechnology.flutter) + var builder = Purchasely.apiKey(apiKey) + .appTechnology(.flutter) + .sdkBridgeVersion("6.0.0-rc.1") + + if let userId = (arguments["appUserId"] as? String) ?? (arguments["userId"] as? String), !userId.isEmpty { + builder = builder.appUserId(userId) + } + + builder = builder.runningMode(Self.runningMode(from: arguments["runningMode"])) + builder = builder.logLevel(Self.logLevel(from: arguments["logLevel"])) + builder = builder.storekitSettings(Self.storekitSettings(from: arguments)) - let logLevel = PLYLogger.PLYLogLevel(rawValue: (arguments["logLevel"] as? Int) ?? PLYLogger.PLYLogLevel.debug.rawValue) ?? .debug - let userId = arguments["userId"] as? String - let runningMode = PLYRunningMode(rawValue: (arguments["runningMode"] as? Int) ?? PLYRunningMode.full.rawValue) ?? PLYRunningMode.full - let storeKitSettingRawValue = arguments["storeKit1"] as? Bool ?? false - let storeKitSetting = storeKitSettingRawValue ? StorekitSettings.storeKit1 : StorekitSettings.storeKit2 + if let allowDeeplink = arguments["allowDeeplink"] as? Bool { + Purchasely.allowDeeplink(allowDeeplink) + } + if let allowCampaigns = arguments["allowCampaigns"] as? Bool { + Purchasely.allowCampaigns(allowCampaigns) + } DispatchQueue.main.async { - Purchasely.start(withAPIKey: apiKey, - appUserId: userId, - runningMode: runningMode, - paywallActionsInterceptor: nil, - storekitSettings: storeKitSetting, - logLevel: logLevel) { success, error in - if success { - SwiftPurchaselyFlutterPlugin.isStarted = true - result(success) - } else { + builder.start { error in + if let error = error { result(FlutterError.error(code: "0", message: "Purchasely SDK not configured", error: error)) + } else { + SwiftPurchaselyFlutterPlugin.isStarted = true + result(true) } } } } - private func fetchPresentation(arguments: [String: Any]?, result: @escaping FlutterResult) { + // MARK: - Presentation lifecycle - let placementId = arguments?["placementVendorId"] as? String - let presentationId = arguments?["presentationVendorId"] as? String - let contentId = arguments?["contentId"] as? String + /// Build a `PLYPresentationRequest` from a Dart-side request map. The map + /// shape mirrors `PresentationRequest.toMap()` in + /// `lib/src/presentation_request.dart`. + private func buildRequest(_ args: [String: Any], requestId: String) -> PLYPresentationRequest { + let source = args["source"] as? [String: Any] + let kind = (source?["kind"] as? String) ?? "defaultSource" + let id = source?["id"] as? String + let contentId = args["contentId"] as? String - if let placementId = placementId { - Purchasely.fetchPresentation(for: placementId, contentId: contentId, fetchCompletion: { [weak self] presentation, error in - guard let `self` = self else { return } - DispatchQueue.main.async { - if let error = error { - result(FlutterError.error(code: "-1", message: "Error while fetching presentation", error: error)) - } else if let presentation = presentation { - SwiftPurchaselyFlutterPlugin.presentationsLoaded.removeAll(where: { $0.id == presentation.id }) - SwiftPurchaselyFlutterPlugin.presentationsLoaded.append(presentation) - result(presentation.toMap) - } - } - }) { [weak self] productResult, plan in - guard let `self` = self else { return } - let value: [String: Any] = ["result": productResult.rawValue, "plan": plan?.toMap ?? [:]] - DispatchQueue.main.async { - SwiftPurchaselyFlutterPlugin.purchaseResult?(value) - } + let builder: PLYPresentationBuilder = { + switch kind { + case "placementId": + return id.map { PLYPresentationBuilder.from(placementId: $0) } ?? .default() + case "screenId": + return id.map { PLYPresentationBuilder.from(screenId: $0) } ?? .default() + default: + return .default() } - } else if let presentationId = presentationId { - Purchasely.fetchPresentation(with: presentationId, contentId: contentId, fetchCompletion: { [weak self] presentation, error in - guard let `self` = self else { return } - DispatchQueue.main.async { - if let error = error { - result(FlutterError.error(code: "-1", message: "Error while fetching presentation", error: error)) - } else if let presentation = presentation { - SwiftPurchaselyFlutterPlugin.presentationsLoaded.removeAll(where: { $0.id == presentation.id }) - SwiftPurchaselyFlutterPlugin.presentationsLoaded.append(presentation) - result(presentation.toMap) - } - } - }) { [weak self] productResult, plan in - guard let `self` = self else { return } - let value: [String: Any] = ["result": productResult.rawValue, "plan": plan?.toMap ?? [:]] - DispatchQueue.main.async { - SwiftPurchaselyFlutterPlugin.purchaseResult?(value) - } - } - } - } + }() - private func presentPresentation(arguments: [String: Any]?, result: @escaping FlutterResult) { - guard let presentationMap = arguments?["presentation"] as? [String: Any] else { - result(FlutterError.error(code: "-1", message: "Presentation cannot be nil", error: nil)) - return + if let contentId = contentId { _ = builder.contentId(contentId) } + if let hex = args["backgroundColor"] as? String, let color = UIColor.ply_from(hex: hex) { + _ = builder.backgroundColor(color) + } + if let hex = args["progressColor"] as? String, let color = UIColor.ply_from(hex: hex) { + _ = builder.progressColor(color) } - SwiftPurchaselyFlutterPlugin.purchaseResult = result - - guard let presentationId = presentationMap["id"] as? String, - let placementId = presentationMap["placementId"] as? String, - let presentationLoaded = SwiftPurchaselyFlutterPlugin.presentationsLoaded.filter({ $0.id == presentationId && $0.placementId == placementId }).first, - let controller = presentationLoaded.controller else { - result(FlutterError.error(code: "-1", message: "Presentation not loaded", error: nil)) - return + // Builder-seeded callbacks are transferred onto the loaded presentation + // automatically by the SDK. They run on the main actor; we emit on the + // EventChannel sink (main queue) directly. + _ = builder.onPresented { [weak self] presentation, error in + self?.presentationEventHandler.emit([ + "event": "onPresented", + "requestId": requestId, + "presentation": presentation.map { self?.presentationToMap($0, requestId: requestId) ?? [:] } as Any?, + "error": error.map { Self.errorToMap($0) } as Any?, + ]) } - SwiftPurchaselyFlutterPlugin.presentationsLoaded.removeAll(where: { $0.id == presentationId }) + _ = builder.onClose { [weak self] in + // iOS exposes `onClose` (close-requested semantics). Renamed to + // `onCloseRequested` on the wire so the Dart-side façade matches + // the cross-platform contract. + self?.presentationEventHandler.emit([ + "event": "onCloseRequested", + "requestId": requestId, + ]) + } - let navCtrl = UINavigationController(rootViewController: controller) - navCtrl.navigationBar.isTranslucent = true - navCtrl.navigationBar.setBackgroundImage(UIImage(), for: .default) - navCtrl.navigationBar.shadowImage = UIImage() - navCtrl.navigationBar.tintColor = UIColor.white + _ = builder.onDismissed { [weak self] outcome in + let presentation = SwiftPurchaselyFlutterPlugin.loadedPresentations[requestId] + self?.presentationEventHandler.emit([ + "event": "onDismissed", + "requestId": requestId, + "outcome": self?.outcomeToMap(outcome, presentation: presentation, error: nil, requestId: requestId) as Any?, + ]) + } - self.presentedPresentationViewController = navCtrl + let request = builder.build() + SwiftPurchaselyFlutterPlugin.requests[requestId] = request + return request + } - if let isFullscreen = arguments?["isFullscreen"] as? Bool, isFullscreen { - navCtrl.modalPresentationStyle = .fullScreen + private func preload(_ args: [String: Any]?, result: @escaping FlutterResult) { + guard let args = args, let requestId = args["requestId"] as? String, !requestId.isEmpty else { + result(FlutterError(code: "ARG_INVALID", message: "requestId required", details: nil)) + return } - - DispatchQueue.main.async { - if presentationLoaded.isFlow { - presentationLoaded.display() + let request = buildRequest(args, requestId: requestId) + request.preload { [weak self] presentation, error in + guard let self = self else { return } + if let presentation = presentation { + SwiftPurchaselyFlutterPlugin.loadedPresentations[requestId] = presentation + self.presentationEventHandler.emit([ + "event": "onLoaded", + "requestId": requestId, + "presentation": self.presentationToMap(presentation, requestId: requestId), + ]) + result(self.presentationToMap(presentation, requestId: requestId)) } else { - Purchasely.showController(navCtrl, type: .productPage) + let errMap = error.map { Self.errorToMap($0) } ?? ["code": "Unknown", "message": "unknown"] + self.presentationEventHandler.emit([ + "event": "onLoaded", + "requestId": requestId, + "error": errMap, + ]) + result(FlutterError(code: "PRELOAD", + message: error?.localizedDescription ?? "preload failed", + details: errMap)) } - } } - private func clientPresentationDisplayed(arguments: [String: Any]?) { - guard let presentationMap = arguments?["presentation"] as? [String: Any] else { - print("Presentation cannot be nil") + private func display(_ args: [String: Any]?, result: @escaping FlutterResult) { + guard let args = args, let requestId = args["requestId"] as? String, !requestId.isEmpty else { + result(FlutterError(code: "ARG_INVALID", message: "requestId required", details: nil)) return } + let request = SwiftPurchaselyFlutterPlugin.requests[requestId] ?? buildRequest(args, requestId: requestId) - guard let presentationId = presentationMap["id"] as? String, - let placementId = presentationMap["placementId"] as? String, - let presentationLoaded = SwiftPurchaselyFlutterPlugin.presentationsLoaded.filter({ $0.id == presentationId && $0.placementId == placementId }).first else { return } + let transitionMap = args["transition"] as? [String: Any] + let displayMode = Self.parseTransition(transitionMap) - Purchasely.clientPresentationOpened(with: presentationLoaded) + request.display(transition: displayMode) { [weak self] presentation, error in + guard let self = self else { return } + // The Dart-side `.display()` Future resolves at *dismiss* time, not + // here. We don't `result(...)` with the outcome — that's emitted via + // the `onDismissed` event and the Dart façade resolves its Future + // from there. We acknowledge the dispatch via `result(true)` on + // success, and synthesise the error-path callbacks on failure. + if let presentation = presentation { + SwiftPurchaselyFlutterPlugin.loadedPresentations[requestId] = presentation + result(true) + } else if let error = error { + // Synthesise onPresented(nil, error) so the Dart-side builder + // onPresented handler fires uniformly across platforms. + self.presentationEventHandler.emit([ + "event": "onPresented", + "requestId": requestId, + "presentation": nil as Any?, + "error": Self.errorToMap(error), + ]) + // Also synthesise onDismissed with the error outcome. + let outcome = self.outcomeToMap( + PLYPresentationOutcome(), + presentation: nil, + error: error, + requestId: requestId + ) + self.presentationEventHandler.emit([ + "event": "onDismissed", + "requestId": requestId, + "outcome": outcome, + ]) + result(true) + } else { + result(true) + } + } } - private func clientPresentationClosed(arguments: [String: Any]?) { - guard let presentationMap = arguments?["presentation"] as? [String: Any] else { - print("Presentation cannot be nil") - return + private func closePresentation(_ args: [String: Any]?, result: @escaping FlutterResult) { + let requestId = args?["requestId"] as? String + if let id = requestId, let presentation = SwiftPurchaselyFlutterPlugin.loadedPresentations[id] { + presentation.close() + } else { + // No per-request handle — fall back to closing every loaded + // presentation (best-effort; the iOS SDK doesn't expose a global + // "closeAll"). + SwiftPurchaselyFlutterPlugin.loadedPresentations.values.forEach { $0.close() } } - - guard let presentationId = presentationMap["id"] as? String, - let placementId = presentationMap["placementId"] as? String, - let presentationLoaded = SwiftPurchaselyFlutterPlugin.presentationsLoaded.filter({ $0.id == presentationId && $0.placementId == placementId }).first else { return } - - Purchasely.clientPresentationClosed(with: presentationLoaded) + result(true) } - private func presentPresentationWithIdentifier(arguments: [String: Any]?, result: @escaping FlutterResult) { - - let presentationVendorId = arguments?["presentationVendorId"] as? String - let contentId = arguments?["contentId"] as? String - - let controller = Purchasely.presentationController(with: presentationVendorId, - contentId: contentId, - loaded: nil) { productResult, plan in - let value: [String: Any] = ["result": productResult.rawValue, "plan": plan?.toMap ?? [:]] - DispatchQueue.main.async { - result(value) - } + private func back(_ args: [String: Any]?, result: @escaping FlutterResult) { + let requestId = args?["requestId"] as? String + if let id = requestId, let presentation = SwiftPurchaselyFlutterPlugin.loadedPresentations[id] { + presentation.back() } + result(true) + } - if let controller = controller { - let navCtrl = UINavigationController(rootViewController: controller) - navCtrl.navigationBar.isTranslucent = true - navCtrl.navigationBar.setBackgroundImage(UIImage(), for: .default) - navCtrl.navigationBar.shadowImage = UIImage() - navCtrl.navigationBar.tintColor = UIColor.white - - self.presentedPresentationViewController = navCtrl - - if let isFullscreen = arguments?["isFullscreen"] as? Bool, isFullscreen { - navCtrl.modalPresentationStyle = .fullScreen - } + // MARK: - Action interceptor - DispatchQueue.main.async { - Purchasely.showController(navCtrl, type: .productPage) - } - } else { - result(FlutterError.error(code: "-1", message: "You are using a running mode that prevent paywalls to be displayed", error: nil)) + private func registerInterceptor(_ args: [String: Any]?, result: @escaping FlutterResult) { + guard let kindWire = args?["kind"] as? String, + let action = Self.actionFromWire(kindWire) else { + result(FlutterError(code: "ARG_INVALID", message: "unknown action kind", details: nil)) + return } - } - private func presentPresentationForPlacement(arguments: [String: Any]?, result: @escaping FlutterResult) { + Purchasely.interceptAction(action) { [weak self] info, params, completion in + guard let self = self else { completion(.notHandled); return } + let id = "ply_ic_\(Int.random(in: 0.. PLYRunningMode { + if let value = raw as? Int { + return PLYRunningMode(rawValue: value) ?? .observer } - let presentationVendorId = arguments["presentationVendorId"] as? String - let contentId = arguments["contentId"] as? String + if let value = raw as? String, value.lowercased() == "full" { + return .full + } + return .observer + } - let controller = Purchasely.productController(for: productVendorId, - with: presentationVendorId, - contentId: contentId, - loaded: nil) { productResult, plan in - let value: [String: Any] = ["result": productResult.rawValue, "plan": plan?.toMap ?? [:]] - DispatchQueue.main.async { - result(value) + private static func logLevel(from raw: Any?) -> PLYLogger.PLYLogLevel { + if let value = raw as? Int { + return PLYLogger.PLYLogLevel(rawValue: value) ?? .error + } + if let value = raw as? String { + switch value.lowercased() { + case "debug": return .debug + case "info": return .info + case "warn": return .warn + default: return .error } } - - if let controller = controller { - let navCtrl = UINavigationController(rootViewController: controller) - navCtrl.navigationBar.isTranslucent = true - navCtrl.navigationBar.setBackgroundImage(UIImage(), for: .default) - navCtrl.navigationBar.shadowImage = UIImage() - navCtrl.navigationBar.tintColor = UIColor.white - - self.presentedPresentationViewController = navCtrl - - if let isFullscreen = arguments["isFullscreen"] as? Bool, isFullscreen { - navCtrl.modalPresentationStyle = .fullScreen + return .error + } + + private static func storekitSettings(from arguments: [String: Any]) -> StorekitSettings { + if let value = arguments["storekitVersion"] as? String { + return value == "storeKit1" ? .storeKit1 : .storeKit2 + } + let storeKit1 = arguments["storeKit1"] as? Bool ?? false + return storeKit1 ? .storeKit1 : .storeKit2 + } + + private func presentationToMap(_ p: PLYPresentation, requestId: String) -> [String: Any] { + return [ + "requestId": requestId, + // iOS `id` maps to wire `screenId`. The Dart factory tolerates both + // keys; we send `screenId` for forward compatibility with the + // contract. + "screenId": p.id, + "placementId": p.placementId as Any, + "contentId": NSNull(), + "audienceId": p.audienceId as Any, + "abTestId": p.abTestId as Any, + "abTestVariantId": p.abTestVariantId as Any, + "campaignId": p.campaignId as Any, + "flowId": p.flowId as Any, + "language": p.language, + "type": p.type.rawValue, + "height": p.height, + "plans": p.plans.map { plan -> [String: Any?] in + [ + "planVendorId": plan.planVendorId, + "storeProductId": plan.storeProductId, + "basePlanId": nil as Any?, // iOS doesn't expose basePlanId on PLYPresentationPlan + "offerId": plan.offerId, + ] + }, + ] + } + + private func outcomeToMap(_ outcome: PLYPresentationOutcome, + presentation: PLYPresentation?, + error: Error?, + requestId: String) -> [String: Any?] { + let purchaseResult: String? = { + switch outcome.purchaseResult { + case .purchased: return "purchased" + case .cancelled: return "cancelled" + case .restored: return "restored" + case .none: return nil + @unknown default: return nil } + }() - DispatchQueue.main.async { - Purchasely.showController(navCtrl, type: .productPage) + // Serialize the full PLYPlan (same shape as products/plans elsewhere) so + // the Dart side can parse it into a fully-typed PLYPlan via plyPlanFromMap. + let planMap: [String: Any]? = outcome.plan?.toMap + + // iOS v6 exposes `closeReason` on PLYPresentationOutcome. Serialize via + // `rawDescription`, which matches Android's wire strings — interactive + // dismiss stringifies to "back_system" for cross-platform parity. + // `.none` means no close happened (e.g. a purchase/restore outcome) → send null. + let closeReason: String? = { + switch outcome.closeReason { + case .none: return nil + default: return outcome.closeReason.rawDescription } - } else { - result(FlutterError.error(code: "-1", message: "You are using a running mode that prevent paywalls to be displayed", error: nil)) + }() + + let outcomePresentation = outcome.presentation ?? presentation + + return [ + "presentation": outcomePresentation.map { presentationToMap($0, requestId: requestId) } as Any?, + "purchaseResult": purchaseResult, + "plan": planMap as Any?, + "closeReason": closeReason as Any?, + "error": error.map { Self.errorToMap($0) } as Any?, + ] + } + + private static func errorToMap(_ error: Error) -> [String: Any] { + let ns = error as NSError + return [ + "code": "\(ns.domain).\(ns.code)", + "message": ns.localizedDescription, + ] + } + + private static func interceptorInfoToMap(_ info: PLYInterceptorInfo) -> [String: Any?] { + // Mirror Android's shape — only the keys consumed by the Dart façade. + return [ + "contentId": info.contentId, + "presentation": info.presentation.map { p in + [ + "screenId": p.id, + "placementId": p.placementId as Any, + ] + } as Any?, + ] + } + + private static func actionParamsToMap(_ params: PLYPresentationActionParameters?) -> [String: Any]? { + guard let params = params else { return nil } + var map: [String: Any] = [:] + if let url = params.url?.absoluteString { map["url"] = url } + if let title = params.title { map["title"] = title } + if let plan = params.plan { + map["plan"] = [ + "vendorId": plan.vendorId as Any, + "productId": plan.appleProductId as Any?, + ] + } + if let presentationId = params.presentation { map["presentationId"] = presentationId } + if let placementId = params.placement { map["placementId"] = placementId } + // `webCheckoutProvider` is a non-optional enum with `.none` sentinel; + // forward the raw value so the Dart side can treat .none as "absent". + map["webCheckoutProvider"] = params.webCheckoutProvider.rawValue + if let clientRef = params.clientReferenceId { map["clientReferenceId"] = clientRef } + if let queryParam = params.queryParameterKey { map["queryParameterKey"] = queryParam } + return map + } + + private static func actionFromWire(_ wire: String) -> PLYPresentationAction? { + switch wire { + case "close": return .close + case "close_all": return .closeAll + case "login": return .login + case "navigate": return .navigate + case "purchase": return .purchase + case "restore": return .restore + case "open_presentation": return .openPresentation + case "open_placement": return .openPlacement + case "promo_code": return .promoCode + case "web_checkout": return .webCheckout + default: return nil + } + } + + /// Parses a Dart transition dimension `{ "type": "pixel"|"percentage", "value": }` + /// into a native `PLYDimension` (`.value(Int)` for pixels, `.percentage(Float)` for + /// ratios). Returns `nil` (→ "hug" / size-to-content) when absent or malformed. + private static func parseDimension(_ raw: Any?) -> PLYDimension? { + guard let map = raw as? [String: Any], + let value = (map["value"] as? NSNumber)?.doubleValue else { return nil } + switch map["type"] as? String { + case "pixel": return .value(Int(value.rounded())) + case "percentage": return .percentage(Float(value)) + default: return .percentage(Float(value)) + } + } + + private static func parseTransition(_ map: [String: Any]?) -> PLYDisplayMode? { + guard let map = map, let type = map["type"] as? String else { return nil } + let dismissible = map["dismissible"] as? Bool ?? true + // v6 models drawer/popin size as PLYDimension (width is popin-only, height + // drives drawer + popin). `nil` means "hug" — size to content. + let width = parseDimension(map["width"]) + let height = parseDimension(map["height"]) + switch type { + case "fullScreen": return .fullScreen + case "push": return .push + case "modal": return .modal + case "drawer": return .drawer(height: height, dismissible: dismissible) + case "popin": return .popin(width: width, height: height, dismissible: dismissible) + case "inlinePaywall": return .inlinePaywall + default: return nil + } + } + + // MARK: - Inline native view support + + /// Resolves the controller for the inline platform view. Mirrors Android: + /// the inline view is built from a presentation that was already loaded + /// (via `preload`) and is keyed by the Dart `requestId`. + /// Creation-param contract: `{ "requestId": }`. + static func presentationController(for args: Any?) -> UIViewController? { + guard let creationParams = args as? [String: Any], + let requestId = creationParams["requestId"] as? String, + let presentation = loadedPresentations[requestId] else { + return nil } + return presentation.controller } - private func presentPlanWithIdentifier(arguments: [String: Any]?, result: @escaping FlutterResult) { + // MARK: - Kept v5 surface + private func isAnonymous(result: @escaping FlutterResult) { + result(Purchasely.isAnonymous()) + } + + private func isEligibleForIntroOffer(arguments: [String: Any]?, result: @escaping FlutterResult) { guard let arguments = arguments, let planVendorId = arguments["planVendorId"] as? String else { - result(FlutterError.error(code: "-1", message: "plan vendor id must not be nil", error: nil)) + result(FlutterError.failedArgumentField("planVendorId", type: String.self)) return } - let presentationVendorId = arguments["presentationVendorId"] as? String - let contentId = arguments["contentId"] as? String - let controller = Purchasely.planController(for: planVendorId, - with: presentationVendorId, - contentId: contentId, - loaded:nil) { productResult, plan in - let value: [String: Any] = ["result": productResult.rawValue, "plan": plan?.toMap ?? [:]] - DispatchQueue.main.async { - result(value) + DispatchQueue.main.async { + Purchasely.plan(with: planVendorId) { plan in + plan.isUserEligibleForIntroductoryOffer { res in + result(res) + } + } failure: { error in + result(FlutterError.error(code:"-1", message:"plan \(planVendorId) not found", error: error)) } } - - if let controller = controller { - let navCtrl = UINavigationController(rootViewController: controller) - navCtrl.navigationBar.isTranslucent = true - navCtrl.navigationBar.setBackgroundImage(UIImage(), for: .default) - navCtrl.navigationBar.shadowImage = UIImage() - navCtrl.navigationBar.tintColor = UIColor.white - - self.presentedPresentationViewController = navCtrl - - if let isFullscreen = arguments["isFullscreen"] as? Bool, isFullscreen { - navCtrl.modalPresentationStyle = .fullScreen - } - - DispatchQueue.main.async { - Purchasely.showController(navCtrl, type: .productPage) - } - } else { - result(FlutterError.error(code: "-1", message: "You are using a running mode that prevent paywalls to be displayed", error: nil)) - } } private func restoreAllProducts(_ result: @escaping FlutterResult) { @@ -631,10 +757,13 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { private func synchronize(_ result: @escaping FlutterResult) { DispatchQueue.main.async { + // v6 exposes success/failure callbacks on synchronize(). The Dart + // `Purchasely.synchronize()` Future now resolves on success and throws + // (PlatformException) on failure, instead of the old fire-and-forget. Purchasely.synchronize { - //result(true) + result(true) } failure: { error in - //result(FlutterError.error(code: "-1", message: "Synchronization failed", error: error)) + result(FlutterError.error(code: "-1", message: "Synchronization failed", error: error)) } } } @@ -666,16 +795,29 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { result(true) } - private func readyToOpenDeeplink(readyToOpenDeeplink: Bool?) { - Purchasely.readyToOpenDeeplink(readyToOpenDeeplink ?? true) + private func allowDeeplink(allowDeeplink: Bool?) { + Purchasely.allowDeeplink(allowDeeplink ?? true) } - private func setDefaultPresentationResultHandler(result: @escaping FlutterResult) { - DispatchQueue.main.async { - Purchasely.setDefaultPresentationResultHandler { productResult, plan in - let value: [String: Any] = ["result": productResult.rawValue, "plan": plan?.toMap ?? [:]] - result(value) + private func allowCampaigns(allowCampaigns: Bool?) { + Purchasely.allowCampaigns(allowCampaigns ?? true) + } + + private func setDefaultPresentationDismissHandler(result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + Purchasely.setDefaultPresentationDismissHandler { [weak self] outcome in + guard let self = self else { return } + self.presentationEventHandler.emit([ + "event": "onDefaultPresentationDismissed", + "outcome": self.outcomeToMap( + outcome, + presentation: nil, + error: nil, + requestId: "" + ), + ]) } + result(true) } } @@ -774,14 +916,14 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } } - private func isDeeplinkHandled(_ deeplink: String?, result: @escaping FlutterResult) { + private func handleDeeplink(_ deeplink: String?, result: @escaping FlutterResult) { guard let deeplink = deeplink, let url = URL(string: deeplink) else { result(FlutterError.error(code: "-1", message: "deeplink must not be nil", error: nil)) return } DispatchQueue.main.async { - result(Purchasely.isDeeplinkHandled(deeplink: url)) + result(Purchasely.handleDeeplink(url)) } } @@ -807,17 +949,6 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } } - private func presentSubscriptions() { - if let controller = Purchasely.subscriptionsController() { - let navCtrl = UINavigationController.init(rootViewController: controller) - navCtrl.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: navCtrl, action: #selector(UIViewController.close)) - - DispatchQueue.main.async { - Purchasely.showController(navCtrl, type: .subscriptionList) - } - } - } - private func setThemeMode(arguments: [String: Any]?) { guard let arguments = arguments, let mode = arguments["mode"] as? Int, let themeMode = Purchasely.PLYThemeMode(rawValue: mode) else { return @@ -891,7 +1022,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } Purchasely.setUserAttribute(withStringValue: value, forKey: key, processingLegalBasis: processingLegalBasis) } - + private func setUserAttributeWithStringArray(arguments: [String: Any]?) { guard let (key, value, processingLegalBasis) = mapUserAttributesCallArguments(arguments: arguments, type: [String].self) else { return @@ -905,7 +1036,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } Purchasely.setUserAttribute(withIntValue: value, forKey: key, processingLegalBasis: processingLegalBasis) } - + private func setUserAttributeWithIntArray(arguments: [String: Any]?) { guard let (key, value, processingLegalBasis) = mapUserAttributesCallArguments(arguments: arguments, type: [Int].self) else { return @@ -919,7 +1050,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } Purchasely.setUserAttribute(withDoubleValue: value, forKey: key, processingLegalBasis: processingLegalBasis) } - + private func setUserAttributeWithDoubleArray(arguments: [String: Any]?) { guard let (key, value, processingLegalBasis) = mapUserAttributesCallArguments(arguments: arguments, type: [Double].self) else { return @@ -933,7 +1064,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } Purchasely.setUserAttribute(withBoolValue: value, forKey: key, processingLegalBasis: processingLegalBasis) } - + private func setUserAttributeWithBooleanArray(arguments: [String: Any]?) { guard let (key, value, processingLegalBasis) = mapUserAttributesCallArguments(arguments: arguments, type: [Bool].self) else { return @@ -990,7 +1121,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { private func clearUserAttributes() { Purchasely.clearUserAttributes() } - + private func clearBuiltInAttributes() { Purchasely.clearBuiltInAttributes() } @@ -1015,55 +1146,10 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } } - private func setPaywallActionInterceptor(result: @escaping FlutterResult) { - DispatchQueue.main.async { - Purchasely.setPaywallActionsInterceptor { [weak self] action, parameters, info, onProcessAction in - guard let `self` = self else { return } - self.onProcessActionHandler = onProcessAction - var value = [String: Any]() - - let actionString: String = switch action { - case .login: - "login" - case .purchase: - "purchase" - case .close: - "close" - case .closeAll: - "close_all" - case .restore: - "restore" - case .navigate: - "navigate" - case .promoCode: - "promo_code" - case .openPresentation: - "open_presentation" - case .openPlacement: - "open_placement" - case .webCheckout: - "web_checkout" - } - - value["action"] = actionString - value["info"] = info?.toMap ?? [:] - value["parameters"] = parameters?.toMap ?? [:] - - result(value) - } - } - } - - private func onProcessAction(_ proceed: Bool) { - DispatchQueue.main.async { [weak self] in - self?.onProcessActionHandler?(proceed) - } - } - private func userDidConsumeSubscriptionContent() { Purchasely.userDidConsumeSubscriptionContent() } - + private func setDynamicOffering(arguments: [String: Any]?, result: @escaping FlutterResult) { guard let arguments = arguments, let reference = arguments["reference"] as? String, @@ -1071,7 +1157,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { result(FlutterError.error(code: "-1", message: "reference and planVendorId must not be nil", error: nil)) return } - + let offerVendorId = arguments["offerVendorId"] as? String DispatchQueue.main.async { @@ -1080,7 +1166,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { }) } } - + private func getDynamicOfferings(result: @escaping FlutterResult) { DispatchQueue.main.async { Purchasely.getDynamicOfferings { offerings in @@ -1089,30 +1175,30 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { offerings.forEach( { offering in // create new dictionary for each offering var map = [String: String]() - + map["reference"] = offering.reference map["planVendorId"] = offering.planId - + if let offerId = offering.offerId { map["offerVendorId"] = offerId } - + list.append(map) }) result(list) } } } - + private func removeDynamicOffering(arguments: [String: Any]?) { guard let arguments = arguments, let reference = arguments["reference"] as? String else { return } - + Purchasely.removeDynamicOffering(reference: reference) } - + private func clearDynamicOfferings() { Purchasely.clearDynamicOfferings() } @@ -1137,16 +1223,38 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } Purchasely.revokeDataProcessingConsent(for: purposes) } - + private func setDebugMode(arguments: [String: Any]?) { guard let arguments, let enabled = arguments["debugMode"] as? Bool else { return } - + Purchasely.setDebugMode(enabled: enabled) } } +// MARK: - Presentation EventChannel handler + +final class PresentationEventHandler: NSObject, FlutterStreamHandler { + private var sink: FlutterEventSink? + + func onListen(withArguments _: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + sink = events + return nil + } + + func onCancel(withArguments _: Any?) -> FlutterError? { + sink = nil + return nil + } + + func emit(_ payload: [String: Any?]) { + DispatchQueue.main.async { [weak self] in + self?.sink?(payload.compactMapValues { $0 }) + } + } +} + extension FlutterError { static let nilArgument = FlutterError( code: "argument.nil", @@ -1176,7 +1284,15 @@ class SwiftEventHandler: NSObject, FlutterStreamHandler, PLYEventDelegate { func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { self.eventSink = events - Purchasely.setEventDelegate(self) + // Use the closure-based v6 API (setEventCallback) — more reliable than + // the ObjC delegate API (setEventDelegate) in SDK v6 RC+. + Purchasely.setEventCallback { [weak self] event, properties in + guard let self = self, let sink = self.eventSink else { return } + let name = NSString.fromPLYEvent(event) + DispatchQueue.main.async { + sink(["name": name, "properties": properties ?? [:]]) + } + } return nil } @@ -1188,8 +1304,9 @@ class SwiftEventHandler: NSObject, FlutterStreamHandler, PLYEventDelegate { func eventTriggered(_ event: PLYEvent, properties: [String : Any]?) { guard let eventSink = self.eventSink else { return } + let name = NSString.fromPLYEvent(event) DispatchQueue.main.async { - eventSink(["name": event.name, "properties": properties ?? [:]]) + eventSink(["name": name, "properties": properties ?? [:]]) } } } @@ -1232,10 +1349,10 @@ class UserAttributesHandler: NSObject, FlutterStreamHandler, PLYUserAttributeDel //Purchasely.setUserAttributeDelegate(nil) return nil } - + func onUserAttributeSet(key: String, type: PLYUserAttributeType, value: Any?, source: PLYUserAttributeSource) { guard let eventSink = self.eventSink else { return } - + var formattedType = "" switch type { case .string: @@ -1312,6 +1429,24 @@ extension UIViewController { } +// MARK: - UIColor helper + +extension UIColor { + static func ply_from(hex: String) -> UIColor? { + var s = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if s.hasPrefix("#") { s.removeFirst() } + guard s.count == 6 || s.count == 8 else { return nil } + if s.count == 6 { s = "FF" + s } + var rgba: UInt64 = 0 + guard Scanner(string: s).scanHexInt64(&rgba) else { return nil } + let a = CGFloat((rgba >> 24) & 0xFF) / 255.0 + let r = CGFloat((rgba >> 16) & 0xFF) / 255.0 + let g = CGFloat((rgba >> 8) & 0xFF) / 255.0 + let b = CGFloat( rgba & 0xFF) / 255.0 + return UIColor(red: r, green: g, blue: b, alpha: a) + } +} + // WARNING: This enum must be strictly identical to the one in the Flutter side (purchasely_flutter.PLYAttribute). enum FlutterPLYAttribute: Int { case firebaseAppInstanceId diff --git a/purchasely/ios/purchasely_flutter.podspec b/purchasely/ios/purchasely_flutter.podspec index 0ed03ec8..cea045c6 100644 --- a/purchasely/ios/purchasely_flutter.podspec +++ b/purchasely/ios/purchasely_flutter.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'purchasely_flutter' - s.version = '1.2.4' + s.version = '6.0.0-rc.2' s.summary = 'Flutter Plugin for Purchasely SDK' s.description = <<-DESC Flutter Plugin for Purchasely SDK @@ -21,7 +21,10 @@ Flutter Plugin for Purchasely SDK s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } s.swift_version = '5.0' - s.dependency 'Purchasely', '5.7.4' + # Pinned to the Purchasely 6.0 SDK — the single Flutter plugin depends on the + # v6 builder DSL (Purchasely.apiKey(...).start), PLYPresentationBuilder, + # PLYPresentationRequest, and the interceptAction(_:handler:) overload. + s.dependency 'Purchasely', '6.0.0-rc.2' s.static_framework = true end diff --git a/purchasely/lib/native_view_widget.dart b/purchasely/lib/native_view_widget.dart index ba13a21d..bc868e24 100644 --- a/purchasely/lib/native_view_widget.dart +++ b/purchasely/lib/native_view_widget.dart @@ -1,77 +1,137 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:purchasely_flutter/purchasely_flutter.dart'; -class PLYPresentationView extends StatelessWidget { - final PLYPresentation? presentation; - final String? placementId; - final String? presentationId; - final String? contentId; - final Function(PresentPresentationResult)? callback; +import 'src/presentation.dart'; +import 'src/presentation_outcome.dart'; +import 'src/presentation_request.dart'; - // Channel name and view type must match the ones defined in the native side. - final MethodChannel channel = MethodChannel('native_view_channel'); - final String viewType = 'io.purchasely.purchasely_flutter/native_view'; +/// Renders a Purchasely presentation inline (embedded) inside the Flutter +/// widget tree, as opposed to a full-screen / modal presentation. +/// +/// The widget preloads the supplied [PLYPresentationRequest] to obtain a stable +/// `requestId`, then hands that id to the native platform view through the +/// `{ "requestId": }` creation params. The native side resolves the +/// preloaded presentation from that id and renders it inline. +/// +/// Lifecycle: the embedded (inline) path surfaces its dismissal/outcome through +/// the same `purchasely-presentation-events` channel as a full-screen +/// presentation, keyed by `requestId`. When the inline presentation is +/// dismissed, the native view emits the same `onDismissed` envelope (with the +/// `display()`-style [PLYPresentationOutcome]) as the modal path, so the request's +/// [PLYPresentationRequest.onDismissed] callback fires for the inline view too. +class PLYPresentationView extends StatefulWidget { + /// The presentation request to render inline. Build it with + /// `PLYPresentationBuilder.placement(...)...build()`. + final PLYPresentationRequest request; - PLYPresentationView({ - this.presentation, - this.placementId, - this.presentationId, - this.contentId, - this.callback, + /// Optional widget shown while the presentation is preloading. + final Widget? loadingBuilder; + + /// Optional builder shown when preloading fails. + final Widget Function(BuildContext context, PLYPresentationError error)? + errorBuilder; + + // View type must match the one defined in the native side. + static const String viewType = 'io.purchasely.purchasely_flutter/native_view'; + + const PLYPresentationView({ + super.key, + required this.request, + this.loadingBuilder, + this.errorBuilder, }); + @override + State createState() => _PLYPresentationViewState(); +} + +class _PLYPresentationViewState extends State { + PLYPresentation? _presentation; + PLYPresentationError? _error; + + @override + void initState() { + super.initState(); + _preload(); + } + + Future _preload() async { + try { + final presentation = await widget.request.preload(); + if (!mounted) return; + setState(() => _presentation = presentation); + } on PLYPresentationError catch (e) { + if (!mounted) return; + setState(() => _error = e); + } catch (e) { + if (!mounted) return; + setState(() => _error = PLYPresentationError(message: e.toString())); + } + } + @override Widget build(BuildContext context) { - final Map creationParams = { - 'presentation': Purchasely.transformPLYPresentationToMap(presentation), - 'presentationId': this.presentationId, - 'placementId': this.placementId, - 'contentId': this.contentId, + final error = _error; + if (error != null) { + return widget.errorBuilder?.call(context, error) ?? const SizedBox(); + } + + final presentation = _presentation; + if (presentation == null) { + return widget.loadingBuilder ?? + const Center(child: CircularProgressIndicator()); + } + + final creationParams = { + 'requestId': presentation.requestId, }; switch (defaultTargetPlatform) { case TargetPlatform.android: - return AndroidView( - viewType: viewType, - layoutDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, - creationParams: creationParams, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: (int id) { - channel.setMethodCallHandler((MethodCall call) { - if (call.method == 'onPresentationResult' && callback != null) { - var viewResult = call.arguments['result']; - var plan = call.arguments['plan']; - callback!(PresentPresentationResult( - PLYPurchaseResult.values[viewResult], - plan != null ? Purchasely.transformToPLYPlan(plan) : null)); - } - return Future.value(null); - }); + // Hybrid composition (the native view lives in the Android view + // hierarchy) so touch events reach the embedded Purchasely paywall. + // A plain virtual-display `AndroidView` does NOT reliably deliver taps + // to the interactive paywall controls (close ✕, plan/purchase buttons), + // which is why inline taps appeared to do nothing. The eager gesture + // recognizer makes the platform view claim the gestures so Flutter does + // not swallow them. + return PlatformViewLink( + viewType: PLYPresentationView.viewType, + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: >{ + Factory( + () => EagerGestureRecognizer()), + }, + ); + }, + onCreatePlatformView: (params) { + final controller = PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: PLYPresentationView.viewType, + layoutDirection: + Directionality.maybeOf(context) ?? TextDirection.ltr, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ); + controller + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..create(); + return controller; }, ); case TargetPlatform.iOS: return SafeArea( - // Wrap UiKitView with SafeArea child: UiKitView( - viewType: viewType, + viewType: PLYPresentationView.viewType, creationParams: creationParams, creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: (int id) { - channel.setMethodCallHandler((MethodCall call) { - if (call.method == 'onPresentationResult' && callback != null) { - var viewResult = call.arguments['result']; - var plan = call.arguments['plan']; - callback!(PresentPresentationResult( - PLYPurchaseResult.values[viewResult], - plan != null - ? Purchasely.transformToPLYPlan(plan) - : null)); - } - return Future.value(null); - }); - }, ), ); default: diff --git a/purchasely/lib/purchasely_flutter.dart b/purchasely/lib/purchasely_flutter.dart index e25ec712..f5f76ce1 100644 --- a/purchasely/lib/purchasely_flutter.dart +++ b/purchasely/lib/purchasely_flutter.dart @@ -3,7 +3,32 @@ import 'dart:developer'; import 'package:flutter/services.dart'; -import 'native_view_widget.dart'; +import 'src/action_interceptor.dart' + show PLYPresentationActionKind, PLYActionInterceptorHandler; +import 'src/bridge.dart' show PurchaselyBridge; +import 'src/ply_models.dart'; +import 'src/ply_transformers.dart'; +import 'src/presentation_outcome.dart' show PLYPresentationOutcome; +import 'src/purchasely_builder.dart' show PLYLogLevel, PurchaselyBuilder; + +// --- Purchasely SDK cross-platform API --- +// +// The presentation API is exposed from `lib/src/` and re-exported here so +// callers can `import 'package:purchasely_flutter/purchasely_flutter.dart';` +// and get both the static `Purchasely` class below (purchases, restore, +// login/logout, attributes, products/plans, subscriptions, events, offerings, +// consent, config) and the builder-based presentation API (`PurchaselyBuilder`, +// `PLYPresentationBuilder`, `PLYPresentation`, `PLYPresentationOutcome`, `PLYTransition`, +// ActionInterceptor…). +export 'src/action_interceptor.dart'; +export 'src/bridge.dart' show PurchaselyBridge; +export 'src/ply_models.dart'; +export 'src/presentation.dart'; +export 'src/presentation_builder.dart'; +export 'src/presentation_outcome.dart'; +export 'src/presentation_request.dart'; +export 'src/purchasely_builder.dart'; +export 'src/transition.dart'; class Purchasely { static const MethodChannel _channel = const MethodChannel('purchasely'); @@ -17,7 +42,49 @@ class Purchasely { static var events; static var purchases; - // --- Public Methods --- + // --- SDK initialisation --- + + /// Start the SDK configuration chain. + /// + /// ```dart + /// await Purchasely.apiKey('') + /// .runningMode(PLYRunningMode.full) + /// .logLevel(PLYLogLevel.error) + /// .stores([PLYStore.google]) + /// .start(); + /// ``` + static PurchaselyBuilder apiKey(String key) => PurchaselyBuilder.apiKey(key); + + // --- Action interceptor --- + + /// Registers a typed interceptor for [kind] actions triggered from a + /// PLYPresentation. The handler returns an `PLYInterceptResult` (or a + /// `Future`). Thin façade over [PurchaselyBridge]. + static Future interceptAction( + PLYPresentationActionKind kind, + PLYActionInterceptorHandler handler, + ) => + PurchaselyBridge.ensureInstalled().registerInterceptor(kind, handler); + + /// Removes the action interceptor previously registered for [kind]. + static Future removeActionInterceptor(PLYPresentationActionKind kind) => + PurchaselyBridge.ensureInstalled().removeActionInterceptor(kind); + + /// Removes all registered action interceptors. + static Future removeAllActionInterceptors() => + PurchaselyBridge.ensureInstalled().removeAllActionInterceptors(); + + /// Registers the global dismiss handler for presentations opened by the SDK + /// itself (campaigns, deeplinks, promoted in-app purchases). + /// + /// The handler receives the rich v6 [PLYPresentationOutcome], including the + /// [PLYPresentationOutcome.presentation] field so the app can identify which + /// campaign/deeplink presentation was closed. + static Future setDefaultPresentationDismissHandler( + void Function(PLYPresentationOutcome outcome) handler, + ) => + PurchaselyBridge.ensureInstalled() + .setDefaultPresentationDismissHandler(handler); /// Removes the user attribute listener static void clearUserAttributeListener() { @@ -85,141 +152,6 @@ class Purchasely { } } - static Future start( - {required final String apiKey, - final List? androidStores = const ['Google'], - required bool storeKit1, - final String? userId, - final PLYLogLevel logLevel = PLYLogLevel.error, - final PLYRunningMode runningMode = PLYRunningMode.full}) async { - return await _channel.invokeMethod('start', { - 'apiKey': apiKey, - 'stores': androidStores, - 'storeKit1': storeKit1, - 'userId': userId, - 'logLevel': logLevel.index, - 'runningMode': runningMode.index - }); - } - - static Future fetchPresentation(String? placementId, - {String? presentationId, String? contentId}) async { - final result = - await _channel.invokeMethod('fetchPresentation', { - 'placementVendorId': placementId, - 'presentationVendorId': presentationId, - 'contentId': contentId - }); - - return transformToPLYPresentation(result); - } - - static Future presentPresentation( - PLYPresentation? presentation, - {bool isFullscreen = false}) async { - final result = - await _channel.invokeMethod('presentPresentation', { - 'presentation': transformPLYPresentationToMap(presentation), - 'isFullscreen': isFullscreen - }); - return PresentPresentationResult(PLYPurchaseResult.values[result['result']], - transformToPLYPlan(result['plan'])); - } - - static PLYPresentationView? getPresentationView({ - PLYPresentation? presentation, - String? presentationId, - String? placementId, - String? contentId, - Function(PresentPresentationResult)? callback, - }) { - return PLYPresentationView( - presentation: presentation, - presentationId: presentationId, - placementId: placementId, - contentId: contentId, - callback: callback); - } - - static Future clientPresentationDisplayed( - PLYPresentation presentation) async { - return await _channel.invokeMethod( - 'clientPresentationDisplayed', { - 'presentation': transformPLYPresentationToMap(presentation) - }); - } - - static Future clientPresentationClosed( - PLYPresentation presentation) async { - return await _channel.invokeMethod( - 'clientPresentationClosed', { - 'presentation': transformPLYPresentationToMap(presentation) - }); - } - - static Future presentPresentationWithIdentifier( - String? presentationVendorId, - {String? contentId, - bool isFullscreen = false}) async { - final result = await _channel - .invokeMethod('presentPresentationWithIdentifier', { - 'presentationVendorId': presentationVendorId, - 'contentId': contentId, - 'isFullscreen': isFullscreen - }); - return PresentPresentationResult(PLYPurchaseResult.values[result['result']], - transformToPLYPlan(result['plan'])); - } - - static Future presentPresentationForPlacement( - String? placementVendorId, - {String? contentId, - bool isFullscreen = false}) async { - final result = await _channel - .invokeMethod('presentPresentationForPlacement', { - 'placementVendorId': placementVendorId, - 'contentId': contentId, - 'isFullscreen': isFullscreen - }); - return PresentPresentationResult(PLYPurchaseResult.values[result['result']], - transformToPLYPlan(result['plan'])); - } - - static Future presentProductWithIdentifier( - String productVendorId, - {String? presentationVendorId, - String? contentId, - bool isFullscreen = false}) async { - final result = await _channel - .invokeMethod('presentProductWithIdentifier', { - 'productVendorId': productVendorId, - 'presentationVendorId': presentationVendorId, - 'contentId': contentId, - 'isFullscreen': isFullscreen - }); - PLYPlan? plan; - if (!result['plan'].isEmpty) plan = transformToPLYPlan(result['plan']); - - return PresentPresentationResult( - PLYPurchaseResult.values[result['result']], plan); - } - - static Future presentPlanWithIdentifier( - String planVendorId, - {String? presentationVendorId, - String? contentId, - bool isFullscreen = false}) async { - final result = await _channel - .invokeMethod('presentPlanWithIdentifier', { - 'planVendorId': planVendorId, - 'presentationVendorId': presentationVendorId, - 'contentId': contentId, - 'isFullscreen': isFullscreen - }); - return PresentPresentationResult(PLYPurchaseResult.values[result['result']], - transformToPLYPlan(result['plan'])); - } - static Future restoreAllProducts() async { final bool restored = await _channel.invokeMethod('restoreAllProducts'); return restored; @@ -231,8 +163,17 @@ class Purchasely { return restored; } - static Future synchronize() async { - return await _channel.invokeMethod('synchronize'); + /// Forces a synchronization of the user's purchases with the Purchasely + /// servers. + /// + /// Since the 6.0 native SDKs expose success/error callbacks on + /// `synchronize()`, the returned [Future] resolves with `true` once the + /// synchronization actually completes and throws a [PlatformException] if it + /// failed — instead of the previous fire-and-forget behaviour. `await` it + /// before chaining a follow-up presentation that targets subscribers. + static Future synchronize() async { + final result = await _channel.invokeMethod('synchronize'); + return result == true; } static Future get anonymousUserId async { @@ -256,9 +197,19 @@ class Purchasely { return restored; } - static Future readyToOpenDeeplink(bool readyToOpenDeeplink) async { - _channel.invokeMethod('readyToOpenDeeplink', - {'readyToOpenDeeplink': readyToOpenDeeplink}); + static Future allowDeeplink(bool allowDeeplink) async { + await _channel.invokeMethod( + 'allowDeeplink', {'allowDeeplink': allowDeeplink}); + } + + /// Allows or defers automatic campaign presentation display at runtime. + /// + /// This flag is independent from [allowDeeplink]. It defaults to `true` in + /// the native SDKs; pass `false` during startup/onboarding to queue + /// campaigns, then `true` when your app is ready to display them. + static Future allowCampaigns(bool allowCampaigns) async { + await _channel.invokeMethod( + 'allowCampaigns', {'allowCampaigns': allowCampaigns}); } static Future setLanguage(String language) async { @@ -266,10 +217,6 @@ class Purchasely { .invokeMethod('setLanguage', {'language': language}); } - static Future close() async { - _channel.invokeMethod('close'); - } - static Future productWithIdentifier(String vendorId) async { final Map result = await _channel.invokeMethod( 'productWithIdentifier', {'vendorId': vendorId}); @@ -319,12 +266,8 @@ class Purchasely { return products; } - static Future presentSubscriptions() async { - _channel.invokeMethod('presentSubscriptions'); - } - static Future displaySubscriptionCancellationInstruction() async { - _channel.invokeMethod('displaySubscriptionCancellationInstruction'); + await _channel.invokeMethod('displaySubscriptionCancellationInstruction'); } static Future> userSubscriptions() async { @@ -392,9 +335,9 @@ class Purchasely { return subscriptions; } - static Future isDeeplinkHandled(String deepLink) async { + static Future handleDeeplink(String deepLink) async { return await _channel.invokeMethod( - 'isDeeplinkHandled', {'deeplink': deepLink}); + 'handleDeeplink', {'deeplink': deepLink}); } static void listenToEvents(Function(PLYEvent) block) { @@ -431,69 +374,6 @@ class Purchasely { {'attribute': attribute.index, 'value': value}); } - static Future - setDefaultPresentationResultHandler() async { - final result = - await _channel.invokeMethod('setDefaultPresentationResultHandler'); - print('Default Presentation Result Handler: $result'); - print(inspect(result)); - return PresentPresentationResult(PLYPurchaseResult.values[result['result']], - transformToPLYPlan(result['plan'])); - } - - static Future - setPaywallActionInterceptor() async { - final result = await _channel.invokeMethod('setPaywallActionInterceptor'); - final Map? plan = result['parameters']['plan']; - final Map? offer = result['parameters']['offer']; - final Map? subscriptionOffer = - result['parameters']['subscriptionOffer']; - - final info = PLYPaywallInfo( - result['info']['contentId'], - result['info']['presentationId'], - result['info']['placementId'], - result['info']['abTestId'], - result['info']['abTestVariantId']); - - final action = PLYPaywallAction.values.firstWhere( - (e) => e.toString() == 'PLYPaywallAction.' + result['action']); - - final parameters = PLYPaywallActionParameters( - url: result['parameters']['url'], - title: result['parameters']['title'], - plan: plan != null ? transformToPLYPlan(plan) : null, - offer: offer != null ? transformToPLYPromoOffer(offer) : null, - subscriptionOffer: subscriptionOffer != null - ? transformToPLYSubscription(subscriptionOffer) - : null, - presentation: result['parameters']['presentation'], - clientReferenceId: result['parameters']['clientReferenceId'], - webCheckoutProvider: result['parameters']['webCheckoutProvider'], - queryParameterKey: result['parameters']['queryParameterKey'], - closeReason: result['parameters']['closeReason'], - ); - - return PaywallActionInterceptorResult(info, action, parameters); - } - - static Future onProcessAction(bool processAction) async { - return await _channel.invokeMethod( - 'onProcessAction', {'processAction': processAction}); - } - - static Future closePresentation() async { - return await _channel.invokeMethod('closePresentation'); - } - - static Future hidePresentation() async { - return await _channel.invokeMethod('hidePresentation'); - } - - static Future showPresentation() async { - return await _channel.invokeMethod('showPresentation'); - } - static Future isAnonymous() async { final bool isAnonymous = await _channel.invokeMethod('isAnonymous'); return isAnonymous; @@ -679,30 +559,6 @@ class Purchasely { _channel.invokeMethod('clearBuiltInAttributes'); } - static void setDefaultPresentationResultCallback(Function callback) { - setDefaultPresentationResultHandler().then((value) { - setDefaultPresentationResultCallback(callback); - try { - callback(value); - } catch (e) { - print( - '[Purchasely] Error with callback for default presentation result handler: $e'); - } - }); - } - - static void setPaywallActionInterceptorCallback(Function callback) { - setPaywallActionInterceptor().then((value) { - setPaywallActionInterceptorCallback(callback); - try { - callback(value); - } catch (e) { - print( - '[Purchasely] Error with callback for paywall action interceptor handler: $e'); - } - }); - } - static Future setThemeMode(PLYThemeMode mode) async { return await _channel .invokeMethod('setThemeMode', {'mode': mode.index}); @@ -746,109 +602,15 @@ class Purchasely { // -- Private Methods -- - static PLYPlan? transformToPLYPlan(Map plan) { - if (plan.isEmpty) return null; + static PLYPlan? transformToPLYPlan(Map plan) => + plyPlanFromMap(plan); - PLYPlanType type = PLYPlanType.unknown; - try { - type = PLYPlanType.values[plan['type']]; - } catch (e) { - print(e); - } - return PLYPlan( - plan['vendorId'], - plan['productId'], - plan['name'], - type, - plan['amount'], - plan['localizedAmount'], - plan['currencyCode'], - plan['currencySymbol'], - plan['price'], - plan['period'], - plan['hasIntroductoryPrice'], - plan['introPrice'], - plan['introAmount'], - plan['introDuration'], - plan['introPeriod'], - plan['hasFreeTrial']); - } - - static PLYPromoOffer? transformToPLYPromoOffer(Map offer) { - if (offer.isEmpty) return null; - - return PLYPromoOffer( - offer['vendorId'], - offer['storeOfferId'], - ); - } + static PLYPromoOffer? transformToPLYPromoOffer(Map offer) => + plyPromoOfferFromMap(offer); static PLYSubscriptionOffer? transformToPLYSubscription( - Map subscriptionOffer) { - if (subscriptionOffer.isEmpty) return null; - - return PLYSubscriptionOffer( - subscriptionOffer['subscriptionId'], - subscriptionOffer['basePlanId'], - subscriptionOffer['offerToken'], - subscriptionOffer['offerId'], - ); - } - - static PLYPresentation? transformToPLYPresentation( - Map presentation) { - if (presentation.isEmpty) return null; - - PLYPresentationType type = PLYPresentationType.normal; - try { - type = PLYPresentationType.values[presentation['type']]; - } catch (e) { - print(e); - } - - List plans = (presentation['plans'] as List) - .map((e) => PLYPresentationPlan(e['planVendorId'], e['storeProductId'], - e['basePlanId'], e['offerId'])) - .toList(); - - Map metadata = {}; - presentation['metadata']?.forEach((key, value) { - metadata[key] = value; - }); - - return PLYPresentation( - presentation['id'], - presentation['placementId'], - presentation['audienceId'], - presentation['abTestId'], - presentation['abTestVariantId'], - presentation['language'], - presentation['height'] ?? 0, - type, - plans, - metadata); - } - - static Map transformPLYPresentationToMap( - PLYPresentation? presentation) { - var presentationMap = new Map(); - - presentationMap['id'] = presentation?.id; - presentationMap['placementId'] = presentation?.placementId; - presentationMap['audienceId'] = presentation?.audienceId; - presentationMap['abTestId'] = presentation?.abTestId; - presentationMap['abTestVariantId'] = presentation?.abTestVariantId; - presentationMap['language'] = presentation?.language; - presentationMap['type'] = presentation?.type.index; - - // Need to convert to list of map if we want to send it over to native bridge - //presentationMap['plans'] = presentation?.plans; - - // No need to send metadata - //presentationMap['metadata'] = presentation?.metadata; - - return presentationMap; - } + Map subscriptionOffer) => + plySubscriptionOfferFromMap(subscriptionOffer); static List transformToDynamicOfferings( List>? offerings) { @@ -939,7 +701,8 @@ class Purchasely { properties['anonymous_user_id'], plans, properties['deeplink_identifier'], - properties['source_identifier'], + // v6 iOS sends placement_id; v5 sent source_identifier. Accept both. + properties['source_identifier'] ?? properties['placement_id'], properties['selected_plan'], properties['previous_selected_plan'], properties['selected_presentation'], @@ -995,10 +758,6 @@ class Purchasely { // -- ENUMS -- -enum PLYLogLevel { debug, info, warn, error } - -enum PLYRunningMode { transactionOnly, observer, paywallObserver, full } - enum PLYAttribute { firebase_app_instance_id, airship_channel_id, @@ -1036,10 +795,6 @@ enum PLYDataProcessingPurpose { enum PLYThemeMode { light, dark, system } -enum PLYPurchaseResult { purchased, cancelled, restored } - -enum PLYPresentationType { normal, fallback, deactivated, client } - enum PLYSubscriptionSource { appleAppStore, googlePlayStore, @@ -1048,28 +803,6 @@ enum PLYSubscriptionSource { none } -enum PLYPlanType { - consumable, - nonConsumable, - autoRenewingSubscription, - nonRenewingSubscription, - unknown -} - -enum PLYPaywallAction { - close, - close_all, - login, - navigate, - purchase, - restore, - open_presentation, - open_placement, - promo_code, - open_flow_step, - web_checkout, -} - enum PLYEventName { APP_INSTALLED, APP_CONFIGURED, @@ -1140,60 +873,6 @@ enum PLYUserAttributeType { // -- CLASSES -- -class PLYPlan { - String? vendorId; - String? productId; - String? name; - PLYPlanType type; - double? amount; - String? localizedAmount; - String? currencyCode; - String? currencySymbol; - String? price; - String? period; - bool? hasIntroductoryPrice; - String? introPrice; - double? introAmount; - String? introDuration; - String? introPeriod; - bool? hasFreeTrial; - - PLYPlan( - this.vendorId, - this.productId, - this.name, - this.type, - this.amount, - this.localizedAmount, - this.currencyCode, - this.currencySymbol, - this.price, - this.period, - this.hasIntroductoryPrice, - this.introPrice, - this.introAmount, - this.introDuration, - this.introPeriod, - this.hasFreeTrial); -} - -class PLYPromoOffer { - String? vendorId; - String? storeOfferId; - - PLYPromoOffer(this.vendorId, this.storeOfferId); -} - -class PLYSubscriptionOffer { - String subscriptionId; - String? basePlanId; - String? offerToken; - String? offerId; - - PLYSubscriptionOffer( - this.subscriptionId, this.basePlanId, this.offerToken, this.offerId); -} - class PLYProduct { String name; String vendorId; @@ -1202,65 +881,6 @@ class PLYProduct { PLYProduct(this.name, this.vendorId, this.plans); } -class PLYPresentationPlan { - String? planVendorId; - String? storeProductId; - String? basePlanId; - String? offerId; - - PLYPresentationPlan( - this.planVendorId, this.storeProductId, this.basePlanId, this.offerId); - - Map toMap() { - return { - 'planVendorId': planVendorId, - 'storeProductId': storeProductId, - 'basePlanId': basePlanId, - 'offerId': offerId, - }; - } -} - -class PLYPresentation { - String? id; - String? placementId; - String? audienceId; - String? abTestId; - String? abTestVariantId; - String language; - int height = 0; - PLYPresentationType type; - List? plans; - Map metadata; - - PLYPresentation( - this.id, - this.placementId, - this.audienceId, - this.abTestId, - this.abTestVariantId, - this.language, - this.height, - this.type, - this.plans, - this.metadata); - - Map toMap() { - return { - 'id': id, - 'placementId': placementId, - 'audienceId': audienceId, - 'abTestId': abTestId, - 'abTestVariantId': abTestVariantId, - 'language': language, - 'height': height, - 'type': type.toString(), - 'plans': plans?.map((plan) => plan.toMap()).toList(), - 'metadata': metadata, - }; - } -} - class PLYSubscription { String? purchaseToken; PLYSubscriptionSource? subscriptionSource; @@ -1286,57 +906,6 @@ class PLYSubscription { this.subscriptionDurationInMonths); } -class PresentPresentationResult { - PLYPurchaseResult result; - PLYPlan? plan; - - PresentPresentationResult(this.result, this.plan); -} - -class PaywallActionInterceptorResult { - PLYPaywallInfo info; - PLYPaywallAction action; - PLYPaywallActionParameters parameters; - - PaywallActionInterceptorResult(this.info, this.action, this.parameters); -} - -class PLYPaywallActionParameters { - String? url; - String? title; - PLYPlan? plan; - PLYPromoOffer? offer; - PLYSubscriptionOffer? subscriptionOffer; - String? presentation; - String? clientReferenceId; - String? queryParameterKey; - String? webCheckoutProvider; - String? closeReason; - - PLYPaywallActionParameters( - {this.url, - this.title, - this.plan, - this.offer, - this.subscriptionOffer, - this.presentation, - this.clientReferenceId, - this.queryParameterKey, - this.webCheckoutProvider, - this.closeReason}); -} - -class PLYPaywallInfo { - String? contentId; - String? presentationId; - String? placementId; - String? abTestId; - String? abTestVariantId; - - PLYPaywallInfo(this.contentId, this.presentationId, this.placementId, - this.abTestId, this.abTestVariantId); -} - class PLYEventPropertyPlan { String? type; String? purchasely_plan_id; diff --git a/purchasely/lib/src/action_interceptor.dart b/purchasely/lib/src/action_interceptor.dart new file mode 100644 index 00000000..986a1228 --- /dev/null +++ b/purchasely/lib/src/action_interceptor.dart @@ -0,0 +1,273 @@ +// Purchasely SDK — Action interceptor API. +// +// Sealed class hierarchy for typed action payloads. Each action carries its +// own parameters. Register a per-action handler with +// `Purchasely.interceptAction(kind, handler)`. The +// handler returns an `PLYInterceptResult` (or a `Future`) to let +// the SDK know how the action was handled. + +import 'dart:async'; + +import 'ply_models.dart'; +import 'ply_transformers.dart'; +import 'presentation.dart'; + +/// Kind of action triggered from a presentation. +enum PLYPresentationActionKind { + close, + closeAll, + login, + navigate, + purchase, + restore, + openPresentation, + openPlacement, + promoCode, + webCheckout, +} + +extension PresentationActionKindWire on PLYPresentationActionKind { + String get wire { + switch (this) { + case PLYPresentationActionKind.close: + return 'close'; + case PLYPresentationActionKind.closeAll: + return 'close_all'; + case PLYPresentationActionKind.login: + return 'login'; + case PLYPresentationActionKind.navigate: + return 'navigate'; + case PLYPresentationActionKind.purchase: + return 'purchase'; + case PLYPresentationActionKind.restore: + return 'restore'; + case PLYPresentationActionKind.openPresentation: + return 'open_presentation'; + case PLYPresentationActionKind.openPlacement: + return 'open_placement'; + case PLYPresentationActionKind.promoCode: + return 'promo_code'; + case PLYPresentationActionKind.webCheckout: + return 'web_checkout'; + } + } + + static PLYPresentationActionKind? fromWire(String? value) { + switch (value) { + case 'close': + return PLYPresentationActionKind.close; + case 'close_all': + return PLYPresentationActionKind.closeAll; + case 'login': + return PLYPresentationActionKind.login; + case 'navigate': + return PLYPresentationActionKind.navigate; + case 'purchase': + return PLYPresentationActionKind.purchase; + case 'restore': + return PLYPresentationActionKind.restore; + case 'open_presentation': + return PLYPresentationActionKind.openPresentation; + case 'open_placement': + return PLYPresentationActionKind.openPlacement; + case 'promo_code': + return PLYPresentationActionKind.promoCode; + case 'web_checkout': + return PLYPresentationActionKind.webCheckout; + default: + return null; + } + } +} + +/// Result returned by an interceptor to the SDK. +enum PLYInterceptResult { success, failed, notHandled } + +extension InterceptResultWire on PLYInterceptResult { + String get wire { + switch (this) { + case PLYInterceptResult.success: + return 'success'; + case PLYInterceptResult.failed: + return 'failed'; + case PLYInterceptResult.notHandled: + return 'notHandled'; + } + } +} + +/// Contextual information passed to every interceptor. +class PLYInterceptorInfo { + final String? contentId; + final PLYPresentation? presentation; + + const PLYInterceptorInfo({this.contentId, this.presentation}); + + factory PLYInterceptorInfo.fromMap(Map? map) { + if (map == null) return const PLYInterceptorInfo(); + final presentationMap = map['presentation']; + return PLYInterceptorInfo( + contentId: map['contentId'] as String?, + presentation: presentationMap is Map + ? PLYPresentation.fromMap(presentationMap) + : null, + ); + } +} + +/// Sealed-ish hierarchy of action payloads. Dart doesn't have sealed classes +/// in stable yet for all SDK versions; we use abstract + `kind` discriminator +/// and downcast via `is` for type-safe access. +abstract class PLYActionPayload { + PLYPresentationActionKind get kind; + const PLYActionPayload(); +} + +class PLYNavigatePayload extends PLYActionPayload { + final String url; + final String? title; + const PLYNavigatePayload({required this.url, this.title}); + @override + PLYPresentationActionKind get kind => PLYPresentationActionKind.navigate; +} + +class PLYPurchasePayload extends PLYActionPayload { + final PLYPlan plan; + final PLYSubscriptionOffer? subscriptionOffer; + final PLYPromoOffer? offer; + const PLYPurchasePayload({ + required this.plan, + this.subscriptionOffer, + this.offer, + }); + @override + PLYPresentationActionKind get kind => PLYPresentationActionKind.purchase; +} + +class PLYClosePayload extends PLYActionPayload { + final String closeReason; + const PLYClosePayload({required this.closeReason}); + @override + PLYPresentationActionKind get kind => PLYPresentationActionKind.close; +} + +class PLYCloseAllPayload extends PLYActionPayload { + final String closeReason; + const PLYCloseAllPayload({required this.closeReason}); + @override + PLYPresentationActionKind get kind => PLYPresentationActionKind.closeAll; +} + +class PLYOpenPresentationPayload extends PLYActionPayload { + final String presentationId; + const PLYOpenPresentationPayload({required this.presentationId}); + @override + PLYPresentationActionKind get kind => + PLYPresentationActionKind.openPresentation; +} + +class PLYOpenPlacementPayload extends PLYActionPayload { + final String placementId; + const PLYOpenPlacementPayload({required this.placementId}); + @override + PLYPresentationActionKind get kind => PLYPresentationActionKind.openPlacement; +} + +class PLYWebCheckoutPayload extends PLYActionPayload { + final String url; + final String clientReferenceId; + final String queryParameterKey; + final String webCheckoutProvider; + const PLYWebCheckoutPayload({ + required this.url, + required this.clientReferenceId, + required this.queryParameterKey, + required this.webCheckoutProvider, + }); + @override + PLYPresentationActionKind get kind => PLYPresentationActionKind.webCheckout; +} + +/// Payload-less actions (login, restore, promoCode) reuse this sentinel. +class _EmptyPayload extends PLYActionPayload { + final PLYPresentationActionKind _kind; + const _EmptyPayload(this._kind); + @override + PLYPresentationActionKind get kind => _kind; +} + +/// Parse an action payload sent by the bridge. +PLYActionPayload? actionPayloadFromMap( + PLYPresentationActionKind kind, Map? rawParameters) { + final parameters = rawParameters ?? const {}; + + Map? _map(Object? value) { + if (value is Map) return value; + return null; + } + + switch (kind) { + case PLYPresentationActionKind.navigate: + final url = parameters['url'] as String?; + if (url == null) return null; + return PLYNavigatePayload( + url: url, + title: parameters['title'] as String?, + ); + case PLYPresentationActionKind.purchase: + final plan = plyPlanFromMap(_map(parameters['plan'])); + if (plan == null) return null; + return PLYPurchasePayload( + plan: plan, + subscriptionOffer: + plySubscriptionOfferFromMap(_map(parameters['subscriptionOffer'])), + offer: plyPromoOfferFromMap(_map(parameters['offer'])), + ); + case PLYPresentationActionKind.close: + return PLYClosePayload( + closeReason: + (parameters['closeReason'] as String?) ?? 'programmatic'); + case PLYPresentationActionKind.closeAll: + return PLYCloseAllPayload( + closeReason: + (parameters['closeReason'] as String?) ?? 'programmatic'); + case PLYPresentationActionKind.openPresentation: + final id = (parameters['presentationId'] ?? parameters['presentation']) + as String?; + if (id == null) return null; + return PLYOpenPresentationPayload(presentationId: id); + case PLYPresentationActionKind.openPlacement: + final id = + (parameters['placementId'] ?? parameters['placement']) as String?; + if (id == null) return null; + return PLYOpenPlacementPayload(placementId: id); + case PLYPresentationActionKind.webCheckout: + final url = parameters['url'] as String?; + final clientReferenceId = parameters['clientReferenceId'] as String?; + final queryParameterKey = parameters['queryParameterKey'] as String?; + final provider = parameters['webCheckoutProvider'] as String?; + if (url == null || + clientReferenceId == null || + queryParameterKey == null || + provider == null) { + return null; + } + return PLYWebCheckoutPayload( + url: url, + clientReferenceId: clientReferenceId, + queryParameterKey: queryParameterKey, + webCheckoutProvider: provider, + ); + case PLYPresentationActionKind.login: + case PLYPresentationActionKind.restore: + case PLYPresentationActionKind.promoCode: + return _EmptyPayload(kind); + } +} + +/// Signature of an action interceptor handler. May return synchronously or +/// asynchronously. +typedef PLYActionInterceptorHandler = FutureOr Function( + PLYInterceptorInfo info, + PLYActionPayload? payload, +); diff --git a/purchasely/lib/src/bridge.dart b/purchasely/lib/src/bridge.dart new file mode 100644 index 00000000..c6714c14 --- /dev/null +++ b/purchasely/lib/src/bridge.dart @@ -0,0 +1,566 @@ +// Purchasely SDK — Dart-side MethodChannel/EventChannel dispatcher. +// +// Wires the presentation façade (`lib/src/presentation*.dart`, +// `lib/src/purchasely_builder.dart`, `lib/src/action_interceptor.dart`) to the +// unified native plugin (one plugin per platform). +// +// Channel contract: +// - MethodChannel : `purchasely` — calls Dart → native +// - EventChannel : `purchasely-presentation-events` — events native → Dart +// +// MethodChannel verbs: +// start, preload, display, close, back, +// registerInterceptor, removeInterceptor, removeAllInterceptors, +// interceptorResolve +// +// EventChannel envelopes — every event carries `event` + `requestId` keys: +// * onLoaded : { event, requestId, presentation?, error? } +// * onPresented : { event, requestId, presentation?, error? } +// * onCloseRequested : { event, requestId } +// * onDismissed : { event, requestId, outcome } +// * interceptorTriggered: { event, requestId = invocationId, kind, info, payload } +// +// Initialisation: the singletons on `PLYPresentationActions` / +// `PLYPresentationRequestActions` are installed lazily the first time a +// presentation entry point is invoked (cf. [PurchaselyBridge.ensureInstalled]). + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import 'action_interceptor.dart'; +import 'ply_transformers.dart'; +import 'presentation.dart'; +import 'presentation_outcome.dart'; +import 'presentation_request.dart'; +import 'transition.dart'; + +// --- Bridge singleton ------------------------------------------------------ + +/// Single dispatcher that owns the MethodChannel + EventChannel and keeps +/// track of in-flight presentations, request-keyed callbacks and registered +/// interceptors. Installed lazily via [ensureInstalled]. +class PurchaselyBridge { + PurchaselyBridge._({ + MethodChannel? methodChannel, + EventChannel? eventChannel, + }) : _method = methodChannel ?? const MethodChannel('purchasely'), + _events = eventChannel ?? + const EventChannel('purchasely-presentation-events'); + + static PurchaselyBridge? _instance; + static bool _wired = false; + + /// Idempotent install: wires the dispatcher into [PLYPresentationActions] and + /// [PLYPresentationRequestActions]. Called automatically by [_install]; + /// exposed for tests that need to inject mock channels. + static PurchaselyBridge ensureInstalled({ + MethodChannel? methodChannel, + EventChannel? eventChannel, + }) { + if (_instance == null || methodChannel != null || eventChannel != null) { + _instance?._dispose(); + _instance = PurchaselyBridge._( + methodChannel: methodChannel, + eventChannel: eventChannel, + ); + _wired = false; + } + final bridge = _instance!; + if (!_wired) { + PLYPresentationActions.instance = _BridgePresentationActions(bridge); + PLYPresentationRequestActions.instance = + _BridgePresentationRequestActions(bridge); + bridge._listenEvents(); + _wired = true; + } + return bridge; + } + + /// Resets the singleton (test helper). + static void debugReset() { + _instance?._dispose(); + _instance = null; + _wired = false; + PLYPresentationActions.instance = _uninitialisedPresentation; + PLYPresentationRequestActions.instance = _uninitialisedRequest; + } + + final MethodChannel _method; + final EventChannel _events; + StreamSubscription? _eventSub; + + /// Active presentation requests keyed by requestId. Holds the live + /// `PLYPresentationRequest` (for callback dispatch) and the `PLYPresentation` + /// once preload resolves (for callbacks reassigned post-preload). + final Map _entries = {}; + + /// Pending interceptor handlers keyed by action wire name. + final Map _interceptors = + {}; + + /// Global dismiss handler for SDK-owned presentations (campaigns, + /// deeplinks, promoted in-app purchases). + void Function(PLYPresentationOutcome outcome)? + _defaultPresentationDismissHandler; + + void _listenEvents() { + _eventSub?.cancel(); + _eventSub = _events.receiveBroadcastStream().listen( + (dynamic raw) { + if (raw is! Map) return; + _dispatchEvent(raw); + }, + onError: (_) { + // Swallow stream errors — they are surfaced via per-call futures. + }, + ); + } + + void _dispose() { + _eventSub?.cancel(); + _eventSub = null; + _entries.clear(); + _interceptors.clear(); + _defaultPresentationDismissHandler = null; + } + + // --- MethodChannel calls ------------------------------------------------- + + Future _preload(PLYPresentationRequest request) async { + _registerRequest(request); + try { + final raw = await _method.invokeMethod( + 'preload', + _argsForRequest(request), + ); + final loaded = _presentationFromRaw(raw, request); + final entry = _entries[request.requestId]; + entry?.presentation = loaded; + return loaded; + } on PlatformException catch (e) { + throw PLYPresentationError( + code: e.code, message: e.message, details: e.details); + } + } + + Future _displayRequest( + PLYPresentationRequest request, + PLYTransition? transition, + ) async { + _registerRequest(request); + final entry = _entries[request.requestId]!; + // Native bridges resolve the Dart-side display Future via the onDismissed + // event — not via the MethodChannel response. The MethodChannel `display` + // returns immediately with `true` once the SDK accepted the display call. + final completer = Completer(); + entry.dismissCompleter = completer; + try { + await _method.invokeMethod( + 'display', + { + ..._argsForRequest(request), + if (transition != null) 'transition': transition.toMap(), + }, + ); + } on PlatformException catch (e) { + final err = PLYPresentationError( + code: e.code, message: e.message, details: e.details); + // If the native side rejected the display synchronously, surface the + // error on the Future *and* clear the pending dismiss completer so a + // stray onDismissed event doesn't double-complete. + entry.dismissCompleter = null; + _entries.remove(request.requestId); + if (!completer.isCompleted) { + completer.complete(PLYPresentationOutcome( + presentation: entry.presentation, + error: err, + )); + } + } + return completer.future; + } + + Future _displayPresentation( + PLYPresentation presentation, + PLYTransition? transition, + ) async { + // For a presentation that originated from a preload, the request is + // already registered. After a prior dismiss the entry was dropped by + // `_handleOnDismissed`, so a re-display() must re-register it (keyed by the + // same requestId) from the presentation handle — otherwise the dismiss + // completer would have nowhere to live and the returned future would hang. + final entry = _entries.putIfAbsent( + presentation.requestId, + () => _RequestEntry(null, presentation: presentation), + ); + entry.presentation = presentation; + final completer = Completer(); + entry.dismissCompleter = completer; + try { + await _method.invokeMethod( + 'display', + { + 'requestId': presentation.requestId, + if (transition != null) 'transition': transition.toMap(), + }, + ); + } on PlatformException catch (e) { + final err = PLYPresentationError( + code: e.code, message: e.message, details: e.details); + // The native side rejected the display synchronously: clear the pending + // dismiss completer and drop the entry so a stray onDismissed can't + // double-complete, then surface the error on the Future. + entry.dismissCompleter = null; + _entries.remove(presentation.requestId); + if (!completer.isCompleted) { + completer.complete(PLYPresentationOutcome( + presentation: presentation, + error: err, + )); + } + } + return completer.future; + } + + Future _close(PLYPresentation presentation) async { + await _method.invokeMethod( + 'close', + {'requestId': presentation.requestId}, + ); + } + + Future _back(PLYPresentation presentation) async { + await _method.invokeMethod( + 'back', + {'requestId': presentation.requestId}, + ); + } + + // --- Interceptor API ---------------------------------------------------- + + Future registerInterceptor( + PLYPresentationActionKind kind, + PLYActionInterceptorHandler handler, + ) async { + _interceptors[kind.wire] = handler; + await _method.invokeMethod( + 'registerInterceptor', + {'kind': kind.wire}, + ); + } + + Future removeActionInterceptor(PLYPresentationActionKind kind) async { + _interceptors.remove(kind.wire); + await _method.invokeMethod( + 'removeInterceptor', + {'kind': kind.wire}, + ); + } + + Future removeAllActionInterceptors() async { + _interceptors.clear(); + await _method.invokeMethod('removeAllInterceptors'); + } + + Future setDefaultPresentationDismissHandler( + void Function(PLYPresentationOutcome outcome) handler, + ) async { + await _method.invokeMethod('setDefaultPresentationDismissHandler'); + _defaultPresentationDismissHandler = handler; + } + + Future _resolveInterceptor( + String invocationId, PLYInterceptResult result) async { + await _method.invokeMethod( + 'interceptorResolve', + { + 'invocationId': invocationId, + 'result': result.wire, + }, + ); + } + + // --- Event dispatch ------------------------------------------------------ + + void _dispatchEvent(Map envelope) { + final eventName = envelope['event'] as String?; + final requestId = envelope['requestId'] as String?; + if (eventName == null) return; + + switch (eventName) { + case 'onLoaded': + _handleOnLoaded(requestId, envelope); + break; + case 'onPresented': + _handleOnPresented(requestId, envelope); + break; + case 'onCloseRequested': + _handleOnCloseRequested(requestId); + break; + case 'onDismissed': + _handleOnDismissed(requestId, envelope); + break; + case 'onDefaultPresentationDismissed': + _handleOnDefaultPresentationDismissed(envelope); + break; + case 'interceptorTriggered': + _handleInterceptorTriggered(envelope); + break; + } + } + + void _handleOnLoaded(String? requestId, Map envelope) { + if (requestId == null) return; + final entry = _entries[requestId]; + // onLoaded only fires for the preload path, where the entry still carries + // the originating request. + final request = entry?.request; + if (request == null) return; + final error = _errorFromMap(envelope['error']); + final pMap = envelope['presentation']; + PLYPresentation? presentation; + if (pMap is Map) { + presentation = _presentationFromRaw(pMap, request); + entry!.presentation = presentation; + } + if (presentation != null) { + request.onLoaded?.call(presentation, error); + } else if (error != null) { + // Surface load failures via onPresented(null, error). + request.onPresented?.call(null, error); + } + } + + void _handleOnPresented(String? requestId, Map envelope) { + if (requestId == null) return; + final entry = _entries[requestId]; + if (entry == null) return; + final pMap = envelope['presentation']; + final request = entry.request; + // Re-parse only when we still have the originating request (preload path); + // on a re-display the entry has no request, so reuse the existing handle + // which already carries the host's (possibly reassigned) callbacks. + final presentation = (pMap is Map && request != null) + ? _presentationFromRaw(pMap, request) + : entry.presentation; + if (presentation != null) { + entry.presentation = presentation; + } + final error = _errorFromMap(envelope['error']); + // Fire the presentation-level handler first (mutable, may have been reassigned), + // then fall back to the request-level handler if the presentation didn't override. + final handler = presentation?.onPresented ?? request?.onPresented; + handler?.call(presentation, error); + } + + void _handleOnCloseRequested(String? requestId) { + if (requestId == null) return; + final entry = _entries[requestId]; + if (entry == null) return; + final handler = + entry.presentation?.onCloseRequested ?? entry.request?.onCloseRequested; + handler?.call(); + } + + void _handleOnDismissed(String? requestId, Map envelope) { + if (requestId == null) return; + final entry = _entries[requestId]; + if (entry == null) return; + final outcome = + _outcomeFromMap(envelope['outcome'], fallback: entry.presentation); + // Routing: prefer the per-presentation/request onDismissed callback. When + // none is set, the dismissal isn't handled locally, so fall back to the + // global default dismiss handler — this lets a host fire-and-forget a + // display() (without awaiting it or setting onDismissed) and still receive + // the outcome centrally. + final handler = + entry.presentation?.onDismissed ?? entry.request?.onDismissed; + if (handler != null) { + handler(outcome); + } else { + _defaultPresentationDismissHandler?.call(outcome); + } + final completer = entry.dismissCompleter; + entry.dismissCompleter = null; + if (completer != null && !completer.isCompleted) { + completer.complete(outcome); + } + // Once dismissed, drop the entry. A subsequent re-display() re-registers + // through `_displayPresentation`. + _entries.remove(requestId); + } + + void _handleOnDefaultPresentationDismissed(Map envelope) { + final handler = _defaultPresentationDismissHandler; + if (handler == null) return; + handler(_outcomeFromMap(envelope['outcome'])); + } + + void _handleInterceptorTriggered(Map envelope) { + final invocationId = envelope['requestId'] as String?; + final kindWire = envelope['kind'] as String?; + if (invocationId == null || kindWire == null) return; + final kind = PresentationActionKindWire.fromWire(kindWire); + if (kind == null) { + _resolveInterceptor(invocationId, PLYInterceptResult.notHandled); + return; + } + final handler = _interceptors[kind.wire]; + if (handler == null) { + _resolveInterceptor(invocationId, PLYInterceptResult.notHandled); + return; + } + final info = PLYInterceptorInfo.fromMap(envelope['info'] as Map?); + final payload = actionPayloadFromMap(kind, envelope['payload'] as Map?); + Future run() async { + try { + return await Future.value(handler(info, payload)); + } catch (_) { + return PLYInterceptResult.failed; + } + } + + run().then((result) => _resolveInterceptor(invocationId, result)); + } + + // --- Helpers ------------------------------------------------------------- + + Map _argsForRequest(PLYPresentationRequest request) { + return Map.from(request.toMap()); + } + + void _registerRequest(PLYPresentationRequest request) { + _entries.putIfAbsent( + request.requestId, + () => _RequestEntry(request), + ); + } + + PLYPresentation _presentationFromRaw( + dynamic raw, PLYPresentationRequest request) { + final map = {}; + if (raw is Map) map.addAll(raw); + map['requestId'] = request.requestId; + final p = PLYPresentation.fromMap(map); + // Seed the mutable callbacks from the originating request so the host app + // gets a usable PLYPresentation handle out of preload() even if it never + // reassigns them. They can still be overridden post-preload. + p.onPresented = request.onPresented; + p.onCloseRequested = request.onCloseRequested; + p.onDismissed = request.onDismissed; + return p; + } + + PLYPresentationOutcome _outcomeFromMap(dynamic raw, + {PLYPresentation? fallback}) { + if (raw is! Map) { + return PLYPresentationOutcome(presentation: fallback); + } + final pMap = raw['presentation']; + PLYPresentation? presentation; + if (pMap is Map) { + final m = {}..addAll(pMap); + m['requestId'] = fallback?.requestId ?? (pMap['requestId'] ?? ''); + presentation = PLYPresentation.fromMap(m); + } else { + presentation = fallback; + } + // Parse the plan with the exact same transformer used for + // PLYPurchasePayload.plan (action_interceptor.dart) so the outcome's plan is a + // fully-typed PLYPlan. + final planRaw = raw['plan']; + final plan = plyPlanFromMap(planRaw is Map ? planRaw : null); + return PLYPresentationOutcome( + presentation: presentation, + purchaseResult: + purchaseResultFromString(raw['purchaseResult'] as String?), + plan: plan, + closeReason: closeReasonFromString(raw['closeReason'] as String?), + error: _errorFromMap(raw['error']), + ); + } + + PLYPresentationError? _errorFromMap(dynamic raw) { + if (raw is! Map) return null; + return PLYPresentationError( + code: raw['code'] as String?, + message: raw['message'] as String?, + details: raw['details'], + ); + } +} + +// --- Per-request bookkeeping ---------------------------------------------- + +class _RequestEntry { + _RequestEntry(this.request, {this.presentation}); + + /// The originating request. Null when the entry was (re-)created from a + /// [PLYPresentation] handle on a re-display, after the original request entry + /// was dropped by [PurchaselyBridge._handleOnDismissed]. + final PLYPresentationRequest? request; + PLYPresentation? presentation; + Completer? dismissCompleter; +} + +// --- Action implementations ----------------------------------------------- + +class _BridgePresentationActions extends PLYPresentationActions { + _BridgePresentationActions(this._bridge); + final PurchaselyBridge _bridge; + + @override + Future display( + PLYPresentation presentation, PLYTransition? transition) => + _bridge._displayPresentation(presentation, transition); + + @override + Future close(PLYPresentation presentation) => + _bridge._close(presentation); + + @override + Future back(PLYPresentation presentation) => + _bridge._back(presentation); +} + +class _BridgePresentationRequestActions extends PLYPresentationRequestActions { + _BridgePresentationRequestActions(this._bridge); + final PurchaselyBridge _bridge; + + @override + Future preload(PLYPresentationRequest request) => + _bridge._preload(request); + + @override + Future display( + PLYPresentationRequest request, PLYTransition? transition) => + _bridge._displayRequest(request, transition); +} + +// --- Sentinels reused by `debugReset` ------------------------------------- + +final PLYPresentationActions _uninitialisedPresentation = + _UninitialisedPresentationActions(); +final PLYPresentationRequestActions _uninitialisedRequest = + _UninitialisedRequestActions(); + +class _UninitialisedPresentationActions extends PLYPresentationActions { + StateError _err() => StateError( + 'Purchasely bridge not initialised — call any presentation entry point first.'); + @override + Future display(_, __) => throw _err(); + @override + Future close(_) => throw _err(); + @override + Future back(_) => throw _err(); +} + +class _UninitialisedRequestActions extends PLYPresentationRequestActions { + StateError _err() => StateError( + 'Purchasely bridge not initialised — call any presentation entry point first.'); + @override + Future preload(_) => throw _err(); + @override + Future display(_, __) => throw _err(); +} diff --git a/purchasely/lib/src/ply_models.dart b/purchasely/lib/src/ply_models.dart new file mode 100644 index 00000000..8bbff69b --- /dev/null +++ b/purchasely/lib/src/ply_models.dart @@ -0,0 +1,110 @@ +// Purchasely SDK — shared public models used by the Dart API. + +/// Product distribution type for a Purchasely plan. +enum PLYPlanType { + consumable, + nonConsumable, + autoRenewingSubscription, + nonRenewingSubscription, + unknown +} + +class PLYPlan { + String? vendorId; + String? productId; + String? basePlanId; + String? name; + PLYPlanType type; + double? amount; + String? localizedAmount; + String? currencyCode; + String? currencySymbol; + String? price; + String? period; + bool? hasIntroductoryPrice; + String? introPrice; + double? introAmount; + String? introDuration; + String? introPeriod; + bool? hasFreeTrial; + bool? hasOfferPrice; + String? offerPrice; + double? offerAmount; + String? offerDuration; + String? offerPeriod; + + PLYPlan( + this.vendorId, + this.productId, + this.name, + this.type, + this.amount, + this.localizedAmount, + this.currencyCode, + this.currencySymbol, + this.price, + this.period, + this.hasIntroductoryPrice, + this.introPrice, + this.introAmount, + this.introDuration, + this.introPeriod, + this.hasFreeTrial, + [this.hasOfferPrice, + this.offerPrice, + this.offerAmount, + this.offerDuration, + this.offerPeriod, + this.basePlanId]) { + hasOfferPrice ??= hasIntroductoryPrice; + offerPrice ??= introPrice; + offerAmount ??= introAmount; + offerDuration ??= introDuration; + offerPeriod ??= introPeriod; + } + + @override + String toString() { + return 'PLYPlan(' + 'vendorId: $vendorId, ' + 'productId: $productId, ' + 'basePlanId: $basePlanId, ' + 'name: $name, ' + 'type: $type, ' + 'amount: $amount, ' + 'localizedAmount: $localizedAmount, ' + 'currencyCode: $currencyCode, ' + 'currencySymbol: $currencySymbol, ' + 'price: $price, ' + 'period: $period, ' + 'hasIntroductoryPrice: $hasIntroductoryPrice, ' + 'introPrice: $introPrice, ' + 'introAmount: $introAmount, ' + 'introDuration: $introDuration, ' + 'introPeriod: $introPeriod, ' + 'hasFreeTrial: $hasFreeTrial, ' + 'hasOfferPrice: $hasOfferPrice, ' + 'offerPrice: $offerPrice, ' + 'offerAmount: $offerAmount, ' + 'offerDuration: $offerDuration, ' + 'offerPeriod: $offerPeriod)'; + } +} + +class PLYPromoOffer { + String? vendorId; + String? storeOfferId; + String? publicId; + + PLYPromoOffer(this.vendorId, this.storeOfferId, [this.publicId]); +} + +class PLYSubscriptionOffer { + String subscriptionId; + String? basePlanId; + String? offerToken; + String? offerId; + + PLYSubscriptionOffer( + this.subscriptionId, this.basePlanId, this.offerToken, this.offerId); +} diff --git a/purchasely/lib/src/ply_transformers.dart b/purchasely/lib/src/ply_transformers.dart new file mode 100644 index 00000000..8a03cf69 --- /dev/null +++ b/purchasely/lib/src/ply_transformers.dart @@ -0,0 +1,92 @@ +// Purchasely SDK — native map to public model transformers. + +import 'ply_models.dart'; + +PLYPlan? plyPlanFromMap(Map? plan) { + if (plan == null || plan.isEmpty) return null; + + final offerPrice = plan['offerPrice'] ?? plan['introPrice']; + final offerAmount = plan['offerAmount'] ?? plan['introAmount']; + final offerDuration = plan['offerDuration'] ?? plan['introDuration']; + final offerPeriod = plan['offerPeriod'] ?? plan['introPeriod']; + final hasOfferPrice = plan['hasOfferPrice'] ?? plan['hasIntroductoryPrice']; + + return PLYPlan( + plan['vendorId'], + plan['productId'], + plan['name'], + plyPlanTypeFromWire(plan['type']), + _toDouble(plan['amount']), + plan['localizedAmount'], + plan['currencyCode'], + plan['currencySymbol'], + plan['price'], + plan['period'], + hasOfferPrice, + offerPrice, + _toDouble(offerAmount), + offerDuration, + offerPeriod, + plan['hasFreeTrial'], + hasOfferPrice, + offerPrice, + _toDouble(offerAmount), + offerDuration, + offerPeriod, + plan['basePlanId'], + ); +} + +PLYPlanType plyPlanTypeFromWire(dynamic rawType) { + if (rawType is int && rawType >= 0 && rawType < PLYPlanType.values.length) { + return PLYPlanType.values[rawType]; + } + if (rawType is String) { + switch (rawType) { + case 'CONSUMABLE': + return PLYPlanType.consumable; + case 'NON_CONSUMABLE': + return PLYPlanType.nonConsumable; + case 'RENEWING_SUBSCRIPTION': + return PLYPlanType.autoRenewingSubscription; + case 'NON_RENEWING_SUBSCRIPTION': + return PLYPlanType.nonRenewingSubscription; + default: + return PLYPlanType.unknown; + } + } + return PLYPlanType.unknown; +} + +PLYPromoOffer? plyPromoOfferFromMap(Map? offer) { + if (offer == null || offer.isEmpty) return null; + + return PLYPromoOffer( + offer['vendorId'], + offer['storeOfferId'], + offer['publicId'], + ); +} + +PLYSubscriptionOffer? plySubscriptionOfferFromMap( + Map? subscriptionOffer) { + if (subscriptionOffer == null || subscriptionOffer.isEmpty) return null; + + final subscriptionId = subscriptionOffer['subscriptionId']; + if (subscriptionId is! String || subscriptionId.isEmpty) return null; + + return PLYSubscriptionOffer( + subscriptionId, + subscriptionOffer['basePlanId'], + subscriptionOffer['offerToken'], + subscriptionOffer['offerId'], + ); +} + +double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is num) return value.toDouble(); + return null; +} diff --git a/purchasely/lib/src/presentation.dart b/purchasely/lib/src/presentation.dart new file mode 100644 index 00000000..951d0a91 --- /dev/null +++ b/purchasely/lib/src/presentation.dart @@ -0,0 +1,231 @@ +// Purchasely SDK — Loaded presentation handle. +// +// A `PLYPresentation` is what the SDK returns once a `PLYPresentationRequest` has +// been preloaded (or displayed). It carries metadata about the screen and +// exposes mutable callbacks the host app can reassign after preload. + +import 'dart:async'; + +import 'presentation_outcome.dart'; +import 'transition.dart'; + +/// Kind of presentation returned by the backend. +enum PLYPresentationType { normal, fallback, deactivated, client } + +PLYPresentationType _typeFromInt(int? raw) { + if (raw == null || raw < 0 || raw >= PLYPresentationType.values.length) { + return PLYPresentationType.normal; + } + return PLYPresentationType.values[raw]; +} + +/// Plan summary embedded in a presentation payload. +class PLYPresentationPlan { + final String? planVendorId; + final String? storeProductId; + final String? basePlanId; + final String? offerId; + + const PLYPresentationPlan({ + this.planVendorId, + this.storeProductId, + this.basePlanId, + this.offerId, + }); + + factory PLYPresentationPlan.fromMap(Map map) { + return PLYPresentationPlan( + planVendorId: map['planVendorId'] as String?, + storeProductId: map['storeProductId'] as String?, + basePlanId: map['basePlanId'] as String?, + offerId: map['offerId'] as String?, + ); + } + + Map toMap() => { + 'planVendorId': planVendorId, + 'storeProductId': storeProductId, + 'basePlanId': basePlanId, + 'offerId': offerId, + }; +} + +/// Indirection used by [PLYPresentation.display] / [close] / [back] so the +/// public API can defer to the bridge without creating a circular import. +abstract class PLYPresentationActions { + /// Singleton wired up by `bridge.dart` once the package is initialised. + static PLYPresentationActions instance = _UninitialisedActions(); + + Future display( + PLYPresentation presentation, PLYTransition? transition); + Future close(PLYPresentation presentation); + Future back(PLYPresentation presentation); +} + +class _UninitialisedActions extends PLYPresentationActions { + StateError _err() => StateError( + 'Purchasely bridge not initialised — call any presentation entry point first.'); + + @override + Future display(_, __) => throw _err(); + @override + Future close(_) => throw _err(); + @override + Future back(_) => throw _err(); +} + +/// A loaded presentation. Returned from `PLYPresentationRequest.preload()` and +/// embedded in [PLYPresentationOutcome.presentation] at dismiss time. +/// +/// Callbacks ([onPresented], [onCloseRequested], [onDismissed]) are mutable +/// so the host app can reassign them between preload and display. +class PLYPresentation { + /// Internal request identifier used by the bridge to route subsequent calls + /// (close/back/display) back to the right native request. + final String requestId; + + /// Public identifier of the screen (aka Purchasely "presentation"). Maps to + /// `presentation.id` on iOS until the iOS native API exposes `screenId`. + final String? screenId; + + final String? placementId; + final String? contentId; + final String? audienceId; + final String? abTestId; + final String? abTestVariantId; + final String? campaignId; + final String? flowId; + final String? language; + final int height; + final PLYPresentationType type; + final List plans; + final Map metadata; + + /// Optional pre-loaded handler — fires once when the presentation has been + /// shown for the first time (or with an error if display failed). + void Function(PLYPresentation? presentation, PLYPresentationError? error)? + onPresented; + + /// Optional close-requested handler — fires when the user taps the native + /// close button (or system back on Android). Does not fire when the + /// presentation is dismissed programmatically. + void Function()? onCloseRequested; + + /// Optional dismiss handler — fires when the presentation is fully + /// dismissed (whatever the reason). Receives the full outcome. + void Function(PLYPresentationOutcome outcome)? onDismissed; + + PLYPresentation({ + required this.requestId, + this.screenId, + this.placementId, + this.contentId, + this.audienceId, + this.abTestId, + this.abTestVariantId, + this.campaignId, + this.flowId, + this.language, + this.height = 0, + this.type = PLYPresentationType.normal, + this.plans = const [], + this.metadata = const {}, + this.onPresented, + this.onCloseRequested, + this.onDismissed, + }); + + /// Builds a [PLYPresentation] from the wire map sent by the native bridge. + /// + /// Tolerant of either wire format (`screenId` or `id`). The iOS bridge maps + /// `id` -> `screenId` once at the SDK boundary; this fallback keeps the + /// Dart-side parsing resilient. + factory PLYPresentation.fromMap(Map map) { + final plansList = (map['plans'] as List?) + ?.whereType() + .map((e) => PLYPresentationPlan.fromMap(e)) + .toList() ?? + const []; + + final metadata = {}; + (map['metadata'] as Map?)?.forEach((key, value) { + if (key is String) metadata[key] = value; + }); + + final rawType = map['type']; + final typeIndex = rawType is int + ? rawType + : rawType is String + ? _typeIndexFromString(rawType) + : null; + + return PLYPresentation( + requestId: map['requestId'] as String? ?? '', + screenId: (map['screenId'] ?? map['id']) as String?, + placementId: map['placementId'] as String?, + contentId: map['contentId'] as String?, + audienceId: map['audienceId'] as String?, + abTestId: map['abTestId'] as String?, + abTestVariantId: map['abTestVariantId'] as String?, + campaignId: map['campaignId'] as String?, + flowId: map['flowId'] as String?, + language: map['language'] as String?, + height: (map['height'] as num?)?.toInt() ?? 0, + type: _typeFromInt(typeIndex), + plans: plansList, + metadata: metadata, + ); + } + + static int? _typeIndexFromString(String value) { + switch (value.toLowerCase()) { + case 'normal': + return 0; + case 'fallback': + return 1; + case 'deactivated': + return 2; + case 'client': + return 3; + default: + return null; + } + } + + Map toMap() => { + 'requestId': requestId, + 'screenId': screenId, + 'placementId': placementId, + 'contentId': contentId, + 'audienceId': audienceId, + 'abTestId': abTestId, + 'abTestVariantId': abTestVariantId, + 'campaignId': campaignId, + 'flowId': flowId, + 'language': language, + 'height': height, + 'type': type.index, + 'plans': plans.map((p) => p.toMap()).toList(), + 'metadata': metadata, + }; + + /// Re-display the presentation (matches `display()` on the native SDKs). + /// + /// The returned future completes at dismiss time with the final outcome. + Future display([PLYTransition? transition]) => + PLYPresentationActions.instance.display(this, transition); + + /// Close the presentation programmatically (matches `close()` on Android). + Future close() => PLYPresentationActions.instance.close(this); + + /// Navigate to the previous flow step or dismiss the current one + /// (matches `back()` on Android). + Future back() => PLYPresentationActions.instance.back(this); +} + +/// Convenience extension so a preload future can be chained directly to display: +/// `await request.preload().display(const PLYTransition.drawer(...))`. +extension FuturePresentationDisplay on Future { + Future display([PLYTransition? transition]) => + then((p) => p.display(transition)); +} diff --git a/purchasely/lib/src/presentation_builder.dart b/purchasely/lib/src/presentation_builder.dart new file mode 100644 index 00000000..420cebd5 --- /dev/null +++ b/purchasely/lib/src/presentation_builder.dart @@ -0,0 +1,146 @@ +// Purchasely SDK — Fluent builder for `PLYPresentationRequest`. + +import 'dart:math'; + +import 'bridge.dart'; +import 'presentation.dart'; +import 'presentation_outcome.dart'; +import 'presentation_request.dart'; + +final _rand = Random.secure(); + +/// Returns a 128-bit hex identifier suitable for cross-isolate routing. +/// +/// The cross-platform contract uses a `requestId` for every +/// [PLYPresentationRequest] so events and lifecycle calls can be routed back from +/// native to Dart. +String _nextRequestId() { + final buf = StringBuffer('ply_'); + for (var i = 0; i < 4; i++) { + buf.write(_rand.nextInt(0xFFFFFFFF).toRadixString(16).padLeft(8, '0')); + } + return buf.toString(); +} + +/// Fluent builder for a [PLYPresentationRequest]. +/// +/// Pick a source via [PLYPresentationBuilder.placement], [.screen] or +/// [.defaultSource], then chain configuration and callbacks, then [.build]. +/// +/// Example: +/// ```dart +/// final outcome = await PLYPresentationBuilder +/// .placement('home_screen') +/// .contentId('article-42') +/// .onPresented((p, err) => print('shown')) +/// .onDismissed((outcome) => print('dismissed: ${outcome.purchaseResult}')) +/// .build() +/// .display(const PLYTransition.modal()); +/// ``` +class PLYPresentationBuilder { + final PLYPresentationSource _source; + String? _contentId; + String? _backgroundColorHex; + String? _progressColorHex; + bool? _displayCloseButton; + bool? _displayBackButton; + + void Function(PLYPresentation presentation, PLYPresentationError? error)? + _onLoaded; + void Function(PLYPresentation? presentation, PLYPresentationError? error)? + _onPresented; + void Function()? _onCloseRequested; + void Function(PLYPresentationOutcome outcome)? _onDismissed; + + PLYPresentationBuilder._(this._source); + + /// Source the presentation from a placement id. + static PLYPresentationBuilder placement(String placementId) => + PLYPresentationBuilder._(PLYPresentationSource.placement(placementId)); + + /// Source the presentation from a specific screen id (`presentation.id` on + /// iOS, `presentation.screenId` on Android). + static PLYPresentationBuilder screen(String screenId) => + PLYPresentationBuilder._(PLYPresentationSource.screen(screenId)); + + /// Source the default presentation. + static PLYPresentationBuilder defaultSource() => + PLYPresentationBuilder._(const PLYPresentationSource.defaultSource()); + + PLYPresentationBuilder contentId(String? id) { + _contentId = id; + return this; + } + + /// Background color of the loading screen, as a hex string (e.g. `#000000`). + PLYPresentationBuilder backgroundColor(String? hex) { + _backgroundColorHex = hex; + return this; + } + + /// Progress / spinner color, as a hex string (e.g. `#FFFFFF`). + PLYPresentationBuilder progressColor(String? hex) { + _progressColorHex = hex; + return this; + } + + /// Whether the SDK should render its close button. + /// Android only at the moment — no-op on iOS. + PLYPresentationBuilder displayCloseButton(bool show) { + _displayCloseButton = show; + return this; + } + + /// Whether the SDK should render its back button. + /// Android only at the moment — no-op on iOS. + PLYPresentationBuilder displayBackButton(bool show) { + _displayBackButton = show; + return this; + } + + PLYPresentationBuilder onLoaded( + void Function(PLYPresentation presentation, PLYPresentationError? error) + handler) { + _onLoaded = handler; + return this; + } + + PLYPresentationBuilder onPresented( + void Function(PLYPresentation? presentation, PLYPresentationError? error) + handler) { + _onPresented = handler; + return this; + } + + PLYPresentationBuilder onCloseRequested(void Function() handler) { + _onCloseRequested = handler; + return this; + } + + PLYPresentationBuilder onDismissed( + void Function(PLYPresentationOutcome outcome) handler) { + _onDismissed = handler; + return this; + } + + /// Build the immutable [PLYPresentationRequest]. A stable [requestId] is + /// generated for the bridge to route events back. + PLYPresentationRequest build() { + // Lazy install of the dispatcher so any presentation entry point + // initialises it, not just PurchaselyBuilder.start(). + PurchaselyBridge.ensureInstalled(); + return PLYPresentationRequest( + requestId: _nextRequestId(), + source: _source, + contentId: _contentId, + backgroundColorHex: _backgroundColorHex, + progressColorHex: _progressColorHex, + displayCloseButton: _displayCloseButton, + displayBackButton: _displayBackButton, + onLoaded: _onLoaded, + onPresented: _onPresented, + onCloseRequested: _onCloseRequested, + onDismissed: _onDismissed, + ); + } +} diff --git a/purchasely/lib/src/presentation_outcome.dart b/purchasely/lib/src/presentation_outcome.dart new file mode 100644 index 00000000..e940c72e --- /dev/null +++ b/purchasely/lib/src/presentation_outcome.dart @@ -0,0 +1,99 @@ +// Purchasely SDK — PLYPresentation outcome models. + +import 'ply_models.dart'; +import 'presentation.dart'; + +/// Result of the purchase action triggered from a presentation. +enum PLYPurchaseResult { purchased, cancelled, restored } + +/// Reason a presentation was closed when no error occurred. +/// +/// Mutually exclusive with [PLYPresentationOutcome.error] — when [error] is +/// non null, [closeReason] is `null`. +/// +/// Mirrors the native `PLYCloseReason` wire contract shared by iOS and +/// Android: `button` (`"button"`), `backSystem` (`"back_system"` — Android +/// system back / iOS interactive swipe-down or nav-pop), and `programmatic` +/// (`"programmatic"`). +enum PLYCloseReason { button, backSystem, programmatic } + +/// Error returned by the native SDK when a presentation could not be displayed. +class PLYPresentationError implements Exception { + /// Native error code (`code` field from `PLYError`). + final String? code; + + /// Human-readable message. + final String? message; + + /// Optional payload (e.g. underlying exception description, native stack). + final dynamic details; + + const PLYPresentationError({this.code, this.message, this.details}); + + @override + String toString() => 'PLYPresentationError(code: $code, message: $message)'; +} + +/// The outcome of a presentation session, delivered when the presentation is +/// dismissed (or fails before display). +/// +/// Five fields, matching the cross-platform contract: +/// * [presentation] — the presentation that produced this outcome, or `null` +/// if the presentation never reached the displayed state (pre-display +/// failure). +/// * [purchaseResult] — the purchase action result. `null` when no purchase +/// happened. +/// * [plan] — the plan involved in the purchase action (if any). +/// * [closeReason] — why the presentation was closed. `null` when an [error] +/// occurred or when no close happened (e.g. a purchase/restore outcome). +/// * [error] — display error when the presentation could not be shown. +/// Mutually exclusive with [closeReason]. +class PLYPresentationOutcome { + final PLYPresentation? presentation; + final PLYPurchaseResult? purchaseResult; + final PLYPlan? plan; + final PLYCloseReason? closeReason; + final PLYPresentationError? error; + + const PLYPresentationOutcome({ + this.presentation, + this.purchaseResult, + this.plan, + this.closeReason, + this.error, + }); + + @override + String toString() => + 'PLYPresentationOutcome(purchaseResult: $purchaseResult, closeReason: $closeReason, error: $error)'; +} + +PLYPurchaseResult? purchaseResultFromString(String? value) { + switch (value) { + case 'purchased': + return PLYPurchaseResult.purchased; + case 'cancelled': + return PLYPurchaseResult.cancelled; + case 'restored': + return PLYPurchaseResult.restored; + case null: + case '': + case 'none': + return null; + default: + return null; + } +} + +PLYCloseReason? closeReasonFromString(String? value) { + switch (value) { + case 'button': + return PLYCloseReason.button; + case 'back_system': + return PLYCloseReason.backSystem; + case 'programmatic': + return PLYCloseReason.programmatic; + default: + return null; + } +} diff --git a/purchasely/lib/src/presentation_request.dart b/purchasely/lib/src/presentation_request.dart new file mode 100644 index 00000000..474d2b9d --- /dev/null +++ b/purchasely/lib/src/presentation_request.dart @@ -0,0 +1,115 @@ +// Purchasely SDK — PLYPresentation request (lifecycle handle). + +import 'dart:async'; + +import 'presentation.dart'; +import 'presentation_outcome.dart'; +import 'transition.dart'; + +/// Indirection used by [PLYPresentationRequest.preload] / [display] so the +/// public API can defer to the bridge without creating a circular import. +abstract class PLYPresentationRequestActions { + static PLYPresentationRequestActions instance = _UninitialisedRequest(); + + Future preload(PLYPresentationRequest request); + Future display( + PLYPresentationRequest request, PLYTransition? transition); +} + +class _UninitialisedRequest extends PLYPresentationRequestActions { + StateError _err() => StateError( + 'Purchasely bridge not initialised — call any presentation entry point first.'); + + @override + Future preload(_) => throw _err(); + @override + Future display(_, __) => throw _err(); +} + +/// Internal source kind used when constructing a request. +enum PLYPresentationSourceKind { defaultSource, placementId, screenId } + +class PLYPresentationSource { + final PLYPresentationSourceKind kind; + final String? id; + + const PLYPresentationSource._(this.kind, this.id); + + const PLYPresentationSource.defaultSource() + : this._(PLYPresentationSourceKind.defaultSource, null); + const PLYPresentationSource.placement(String id) + : this._(PLYPresentationSourceKind.placementId, id); + const PLYPresentationSource.screen(String id) + : this._(PLYPresentationSourceKind.screenId, id); + + Map toMap() => { + 'kind': kind.name, + if (id != null) 'id': id, + }; +} + +/// A configured presentation, ready to be preloaded or displayed. +/// +/// Build one through [PLYPresentationBuilder] (in `presentation_builder.dart`). +/// +/// Calling [preload] fetches the presentation from the backend without +/// presenting it. Calling [display] both fetches it (if not preloaded) and +/// shows it; the returned future completes at dismiss time with the final +/// [PLYPresentationOutcome]. +class PLYPresentationRequest { + /// Stable identifier shared between Dart and the native bridge so that + /// callbacks and `close()` calls can be routed back to the right native + /// request instance. + final String requestId; + final PLYPresentationSource source; + final String? contentId; + final String? backgroundColorHex; + final String? progressColorHex; + final bool? displayCloseButton; + final bool? displayBackButton; + + /// Builder-seeded handlers. The bridge wires them to the native callback + /// events. They are copied onto the loaded [PLYPresentation] once preload + /// completes so the host app can also reassign them post-preload. + final void Function( + PLYPresentation presentation, PLYPresentationError? error)? onLoaded; + final void Function( + PLYPresentation? presentation, PLYPresentationError? error)? onPresented; + final void Function()? onCloseRequested; + final void Function(PLYPresentationOutcome outcome)? onDismissed; + + PLYPresentationRequest({ + required this.requestId, + required this.source, + this.contentId, + this.backgroundColorHex, + this.progressColorHex, + this.displayCloseButton, + this.displayBackButton, + this.onLoaded, + this.onPresented, + this.onCloseRequested, + this.onDismissed, + }); + + Map toMap() => { + 'requestId': requestId, + 'source': source.toMap(), + if (contentId != null) 'contentId': contentId, + if (backgroundColorHex != null) 'backgroundColor': backgroundColorHex, + if (progressColorHex != null) 'progressColor': progressColorHex, + if (displayCloseButton != null) + 'displayCloseButton': displayCloseButton, + if (displayBackButton != null) 'displayBackButton': displayBackButton, + }; + + /// Fetch and cache the presentation without displaying it. Resolves with + /// the loaded [PLYPresentation] once the network round-trip completes. + Future preload() => + PLYPresentationRequestActions.instance.preload(this); + + /// Fetch (if needed) and display the presentation. The returned future + /// completes at dismiss time with the final [PLYPresentationOutcome]. + Future display([PLYTransition? transition]) => + PLYPresentationRequestActions.instance.display(this, transition); +} diff --git a/purchasely/lib/src/purchasely_builder.dart b/purchasely/lib/src/purchasely_builder.dart new file mode 100644 index 00000000..dd81294d --- /dev/null +++ b/purchasely/lib/src/purchasely_builder.dart @@ -0,0 +1,119 @@ +// Purchasely SDK — Fluent builder for SDK initialisation. + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import 'bridge.dart'; + +/// Running mode for the SDK. +/// +/// Default is [PLYRunningMode.observer]. +enum PLYRunningMode { observer, full } + +/// Log level for the SDK. +enum PLYLogLevel { debug, info, warn, error } + +/// Storekit transaction handling on iOS. +enum PLYStorekitVersion { storeKit1, storeKit2 } + +/// Android stores supported by the SDK. +enum PLYStore { google, huawei, amazon } + +/// Fluent builder for `Purchasely.start()`. Begin the chain with +/// `PurchaselyBuilder.apiKey('…')`, then chain modifiers, then call +/// `.start()`. +class PurchaselyBuilder { + final String _apiKey; + String? _appUserId; + PLYRunningMode _runningMode; + PLYLogLevel _logLevel; + bool? _allowDeeplink; + bool? _allowCampaigns; + // Android only + List _stores; + // iOS only + PLYStorekitVersion _storekitVersion; + + PurchaselyBuilder._(this._apiKey, + {String? appUserId, + PLYRunningMode runningMode = PLYRunningMode.observer, + PLYLogLevel logLevel = PLYLogLevel.error, + bool? allowDeeplink, + bool? allowCampaigns, + List stores = const [PLYStore.google], + PLYStorekitVersion storekitVersion = PLYStorekitVersion.storeKit2}) + : _appUserId = appUserId, + _runningMode = runningMode, + _logLevel = logLevel, + _allowDeeplink = allowDeeplink, + _allowCampaigns = allowCampaigns, + _stores = List.of(stores), + _storekitVersion = storekitVersion; + + static PurchaselyBuilder apiKey(String key) => PurchaselyBuilder._(key); + + PurchaselyBuilder appUserId(String? id) { + _appUserId = id; + return this; + } + + PurchaselyBuilder runningMode(PLYRunningMode mode) { + _runningMode = mode; + return this; + } + + PurchaselyBuilder logLevel(PLYLogLevel level) { + _logLevel = level; + return this; + } + + /// Whether the SDK is allowed to open deeplinks. + PurchaselyBuilder allowDeeplink(bool allow) { + _allowDeeplink = allow; + return this; + } + + /// Whether the SDK is allowed to display campaign-driven presentations. + /// Omit this modifier to keep each native SDK's default/backend-configured value. + PurchaselyBuilder allowCampaigns(bool allow) { + _allowCampaigns = allow; + return this; + } + + /// Android-only: stores the SDK is allowed to use (priority order). On iOS + /// this modifier is a no-op. + PurchaselyBuilder stores(List stores) { + _stores = List.of(stores); + return this; + } + + /// iOS-only: StoreKit version to use. On Android this modifier is a no-op. + PurchaselyBuilder storekitVersion(PLYStorekitVersion version) { + _storekitVersion = version; + return this; + } + + /// Start the SDK. Resolves to `true` once configured, throws a + /// [PlatformException] otherwise. + Future start() async { + // Wire the dispatcher (idempotent) so subsequent PLYPresentationBuilder / + // PLYPresentationRequest calls have a live channel to talk to. + PurchaselyBridge.ensureInstalled(); + const channel = MethodChannel('purchasely'); + final result = await channel.invokeMethod( + 'start', + { + 'apiKey': _apiKey, + 'appUserId': _appUserId, + 'runningMode': _runningMode.name, + 'logLevel': _logLevel.name, + 'allowDeeplink': _allowDeeplink, + if (_allowCampaigns != null) 'allowCampaigns': _allowCampaigns, + 'stores': _stores.map((s) => s.name).toList(), + 'storekitVersion': _storekitVersion.name, + }, + ); + return result ?? false; + } +} diff --git a/purchasely/lib/src/transition.dart b/purchasely/lib/src/transition.dart new file mode 100644 index 00000000..9e09739e --- /dev/null +++ b/purchasely/lib/src/transition.dart @@ -0,0 +1,133 @@ +// Purchasely SDK — PLYPresentation transitions. + +/// Display transition type for a presentation. +enum PLYTransitionType { + fullScreen, + push, + modal, + drawer, + popin, + inlinePaywall, +} + +/// Unit a [PLYTransitionDimension] value is expressed in. Mirrors the native +/// Android `PLYDimensionType` ( `pixel` / `percentage` ). +enum PLYDimensionType { pixel, percentage } + +/// A single transition dimension (width or height), expressed either as a +/// fixed size in density-independent pixels ([PLYDimensionType.pixel]) or as a +/// ratio of the available screen dimension ([PLYDimensionType.percentage], +/// `0.0`–`1.0`). +/// +/// Mirrors the native `PLYTransitionDimension` (Android) / `PLYDimension` +/// (iOS) used for `drawer`/`popin` sizing. Serializes to +/// `{ 'type': 'pixel' | 'percentage', 'value': }`. +class PLYTransitionDimension { + final PLYDimensionType type; + final double value; + + const PLYTransitionDimension({required this.type, required this.value}); + + /// Fixed size in density-independent pixels. + const PLYTransitionDimension.pixel(this.value) + : type = PLYDimensionType.pixel; + + /// Ratio of the available screen dimension, in `0.0`–`1.0`. + const PLYTransitionDimension.percentage(this.value) + : type = PLYDimensionType.percentage; + + Map toMap() => { + 'type': type == PLYDimensionType.pixel ? 'pixel' : 'percentage', + 'value': value, + }; +} + +/// Background color configuration for a transition. +class PLYTransitionColors { + /// Hex color (e.g. `#000000`) used in light mode. + final String? light; + + /// Hex color used in dark mode. + final String? dark; + + const PLYTransitionColors({this.light, this.dark}); + + Map toMap() => { + if (light != null) 'light': light, + if (dark != null) 'dark': dark, + }; +} + +/// Display transition for a presentation (`PLYPresentationRequest.display(...)`). +/// +/// [width] (popin only) and [height] (drawer + popin) size the surface via the +/// native dimension model — see [PLYTransitionDimension]. [dismissible] +/// defaults to `true` on the native side. +class PLYTransition { + final PLYTransitionType type; + final PLYTransitionDimension? width; + final PLYTransitionDimension? height; + final bool? dismissible; + final PLYTransitionColors? backgroundColors; + + const PLYTransition({ + required this.type, + this.width, + this.height, + this.dismissible, + this.backgroundColors, + }); + + const PLYTransition.fullScreen() : this(type: PLYTransitionType.fullScreen); + const PLYTransition.modal({bool? dismissible}) + : this(type: PLYTransitionType.modal, dismissible: dismissible); + const PLYTransition.push() : this(type: PLYTransitionType.push); + const PLYTransition.drawer({ + PLYTransitionDimension? height, + bool? dismissible, + PLYTransitionColors? backgroundColors, + }) : this( + type: PLYTransitionType.drawer, + height: height, + dismissible: dismissible, + backgroundColors: backgroundColors, + ); + const PLYTransition.popin({ + PLYTransitionDimension? width, + PLYTransitionDimension? height, + bool? dismissible, + PLYTransitionColors? backgroundColors, + }) : this( + type: PLYTransitionType.popin, + width: width, + height: height, + dismissible: dismissible, + backgroundColors: backgroundColors, + ); + + Map toMap() => { + 'type': _typeToWire(type), + if (width != null) 'width': width!.toMap(), + if (height != null) 'height': height!.toMap(), + if (dismissible != null) 'dismissible': dismissible, + if (backgroundColors != null) + 'backgroundColors': backgroundColors!.toMap(), + }; + + static String _typeToWire(PLYTransitionType t) { + switch (t) { + case PLYTransitionType.fullScreen: + return 'fullScreen'; + case PLYTransitionType.push: + return 'push'; + case PLYTransitionType.modal: + return 'modal'; + case PLYTransitionType.drawer: + return 'drawer'; + case PLYTransitionType.popin: + return 'popin'; + case PLYTransitionType.inlinePaywall: + return 'inlinePaywall'; + } + } +} diff --git a/purchasely/pubspec.lock b/purchasely/pubspec.lock index f4bbb729..1c9f4036 100644 --- a/purchasely/pubspec.lock +++ b/purchasely/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -63,50 +63,50 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" path: dependency: transitive description: @@ -164,18 +164,18 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.10" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -185,5 +185,5 @@ packages: source: hosted version: "15.0.0" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/purchasely/pubspec.yaml b/purchasely/pubspec.yaml index 92f847ff..a62aa7aa 100644 --- a/purchasely/pubspec.yaml +++ b/purchasely/pubspec.yaml @@ -1,6 +1,6 @@ name: purchasely_flutter description: Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery. -version: 5.7.3 +version: 6.0.0-rc.1 homepage: https://www.purchasely.com/ environment: diff --git a/purchasely/test/bridge_test.dart b/purchasely/test/bridge_test.dart new file mode 100644 index 00000000..3cfc34a9 --- /dev/null +++ b/purchasely/test/bridge_test.dart @@ -0,0 +1,627 @@ +// Unit tests for `lib/src/bridge.dart` — the Dart-side dispatcher that +// wires the presentation façade to the native MethodChannel/EventChannel. +// +// These tests don't need the native plugin: a fake EventChannel binary +// messenger is installed so we can both observe MethodChannel calls and +// inject events from the "native" side. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PurchaselyBridge', () { + const methodChannelName = 'purchasely'; + const eventChannelName = 'purchasely-presentation-events'; + + late List calls; + late TestDefaultBinaryMessenger messenger; + + /// Helper: emit an EventChannel event as if it were sent by the native + /// side. EventChannel events flow through the platform-default codec, + /// targeted at a channel named identically to the EventChannel, on the + /// reply channel (no name in Flutter < 3 — handled by the test + /// messenger via handlePlatformMessage on the EventChannel name). + Future emitEvent(Map envelope) async { + const codec = StandardMethodCodec(); + final data = codec.encodeSuccessEnvelope(envelope); + await messenger.handlePlatformMessage( + eventChannelName, + data, + (_) {}, + ); + } + + setUp(() { + calls = []; + messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler( + const MethodChannel(methodChannelName), + (call) async { + calls.add(call); + switch (call.method) { + case 'preload': + return { + 'screenId': 'screen_42', + 'placementId': (call.arguments as Map?)?['source']?['id'], + 'height': 600, + 'type': 0, + 'plans': >[], + }; + case 'display': + return true; + case 'close': + case 'back': + return true; + case 'registerInterceptor': + case 'removeInterceptor': + case 'removeAllInterceptors': + case 'interceptorResolve': + case 'setDefaultPresentationDismissHandler': + return true; + case 'start': + return true; + default: + return null; + } + }, + ); + + // Mock the EventChannel so `receiveBroadcastStream()` resolves to a + // stream we can pump events into via emitEvent(). + messenger.setMockMessageHandler(eventChannelName, (message) async { + // Flutter calls `listen`/`cancel` on the event channel — return null + // for either; we'll drive events via handlePlatformMessage instead. + return null; + }); + + // Force-install the bridge with the default channels so the singletons + // get wired against the test messenger. + PurchaselyBridge.debugReset(); + PurchaselyBridge.ensureInstalled(); + }); + + tearDown(() { + PurchaselyBridge.debugReset(); + messenger.setMockMethodCallHandler( + const MethodChannel(methodChannelName), null); + messenger.setMockMessageHandler(eventChannelName, null); + }); + + test('preload() invokes preload and returns a PLYPresentation', () async { + final request = PLYPresentationBuilder.placement('home').build(); + final presentation = await request.preload(); + + expect(calls, hasLength(1)); + expect(calls.single.method, 'preload'); + final args = calls.single.arguments as Map; + expect(args['requestId'], request.requestId); + expect((args['source'] as Map)['kind'], 'placementId'); + expect((args['source'] as Map)['id'], 'home'); + + expect(presentation.screenId, 'screen_42'); + expect(presentation.placementId, 'home'); + expect(presentation.requestId, request.requestId); + }); + + test('display() awaits the onDismissed event before resolving', () async { + final request = PLYPresentationBuilder.placement('home').build(); + // Pre-register the request via preload so the dispatcher tracks it + // (display() uses the same requestId). + await request.preload(); + calls.clear(); + + final futureOutcome = request.display(const PLYTransition.modal()); + // The display call should have been invoked. + // Give the microtask queue a tick so the awaited invokeMethod resolves. + await Future.delayed(Duration.zero); + expect(calls.map((c) => c.method).toList(), ['display']); + + // Fire the onDismissed event from the "native" side. + await emitEvent({ + 'event': 'onDismissed', + 'requestId': request.requestId, + 'outcome': { + 'purchaseResult': 'purchased', + 'closeReason': null, + }, + }); + + final outcome = await futureOutcome; + expect(outcome.purchaseResult, PLYPurchaseResult.purchased); + }); + + test('onLoaded event fires the builder callback', () async { + PLYPresentation? loaded; + PLYPresentationError? capturedErr; + final request = PLYPresentationBuilder.placement('home').onLoaded((p, e) { + loaded = p; + capturedErr = e; + }).build(); + + // Kick off preload but don't await — we want to emit the event after + // the request is registered. + final f = request.preload(); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onLoaded', + 'requestId': request.requestId, + 'presentation': { + 'screenId': 'home_screen', + 'placementId': 'home', + 'height': 800, + 'type': 0, + 'plans': >[], + }, + }); + + await f; + expect(loaded, isNotNull); + expect(loaded!.screenId, 'home_screen'); + expect(capturedErr, isNull); + }); + + test('display() with a PLYTransition forwards the wire payload', () async { + final request = PLYPresentationBuilder.screen('screen_42').build(); + // Don't await — just check the MethodCall arguments. + // ignore: unawaited_futures + request.display(const PLYTransition.modal(dismissible: false)); + await Future.delayed(Duration.zero); + + final displayCall = calls.firstWhere((c) => c.method == 'display'); + final args = displayCall.arguments as Map; + expect((args['source'] as Map)['kind'], 'screenId'); + expect((args['source'] as Map)['id'], 'screen_42'); + expect((args['transition'] as Map)['type'], 'modal'); + expect((args['transition'] as Map)['dismissible'], false); + }); + + test('display() outcome carries 5 fields including closeReason (P0.2)', + () async { + final request = PLYPresentationBuilder.placement('home').build(); + await request.preload(); + calls.clear(); + + // ignore: unawaited_futures + final futureOutcome = request.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onDismissed', + 'requestId': request.requestId, + 'outcome': { + 'purchaseResult': 'purchased', + 'closeReason': 'button', + 'plan': {'vendorId': 'monthly'}, + }, + }); + + final outcome = await futureOutcome; + expect(outcome.purchaseResult, PLYPurchaseResult.purchased); + expect(outcome.closeReason, PLYCloseReason.button); + expect(outcome.error, isNull); + expect(outcome.plan, isA()); + expect(outcome.plan!.vendorId, 'monthly'); + expect(outcome.presentation, isNotNull); + }); + + test('display() outcome plan is a fully-typed PLYPlan', () async { + final request = PLYPresentationBuilder.placement('home').build(); + await request.preload(); + calls.clear(); + + // ignore: unawaited_futures + final futureOutcome = request.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onDismissed', + 'requestId': request.requestId, + 'outcome': { + 'purchaseResult': 'purchased', + 'closeReason': null, + 'plan': { + 'vendorId': 'yearly', + 'productId': 'yearly-product', + 'basePlanId': 'yearly-base', + 'amount': 59.99, + 'currencyCode': 'USD', + 'hasFreeTrial': true, + }, + }, + }); + + final outcome = await futureOutcome; + expect( + outcome.plan, + isA() + .having((p) => p.vendorId, 'vendorId', 'yearly') + .having((p) => p.productId, 'productId', 'yearly-product') + .having((p) => p.basePlanId, 'basePlanId', 'yearly-base') + .having((p) => p.amount, 'amount', 59.99) + .having((p) => p.currencyCode, 'currencyCode', 'USD') + .having((p) => p.hasFreeTrial, 'hasFreeTrial', true), + ); + }); + + test('display() outcome carries error and null closeReason on failure', + () async { + final request = PLYPresentationBuilder.placement('home').build(); + await request.preload(); + calls.clear(); + + // ignore: unawaited_futures + final futureOutcome = request.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onDismissed', + 'requestId': request.requestId, + 'outcome': { + 'error': { + 'code': 'NETWORK', + 'message': 'offline', + }, + }, + }); + + final outcome = await futureOutcome; + expect(outcome.error, isNotNull); + expect(outcome.error!.message, 'offline'); + // P0.2 mutual exclusion: error ⇒ closeReason null + expect(outcome.closeReason, isNull); + }); + + test('default presentation dismiss handler receives rich outcome', + () async { + PLYPresentationOutcome? captured; + + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + captured = outcome; + }); + + final registerCall = calls.firstWhere( + (c) => c.method == 'setDefaultPresentationDismissHandler'); + expect(registerCall.arguments, isNull); + + await emitEvent({ + 'event': 'onDefaultPresentationDismissed', + 'outcome': { + 'purchaseResult': 'restored', + // iOS serializes interactiveDismiss as "back_system" (rawDescription); + // Android sends BACK_SYSTEM.value == "back_system". + 'closeReason': 'back_system', + 'plan': {'vendorId': 'monthly'}, + 'presentation': { + 'screenId': 'campaign_screen', + 'placementId': 'campaign_placement', + 'campaignId': 'cmp_123', + 'height': 720, + 'type': 0, + 'plans': >[], + }, + }, + }); + + expect(captured, isNotNull); + expect(captured!.purchaseResult, PLYPurchaseResult.restored); + expect(captured!.closeReason, PLYCloseReason.backSystem); + expect(captured!.plan?.vendorId, 'monthly'); + expect(captured!.presentation, isNotNull); + expect(captured!.presentation!.screenId, 'campaign_screen'); + expect(captured!.presentation!.campaignId, 'cmp_123'); + }); + + test( + 'display() dismissal falls back to the default handler when no ' + 'onDismissed is set', () async { + PLYPresentationOutcome? viaDefault; + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + viaDefault = outcome; + }); + + // No onDismissed on the builder → the dismissal isn't handled locally. + final request = PLYPresentationBuilder.placement('home').build(); + // Fire-and-forget: display() is intentionally not awaited. + // ignore: unawaited_futures + request.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onDismissed', + 'requestId': request.requestId, + 'outcome': { + 'purchaseResult': 'purchased', + 'closeReason': 'button', + }, + }); + + expect(viaDefault, isNotNull, + reason: 'default handler should catch the unhandled dismissal'); + expect(viaDefault!.purchaseResult, PLYPurchaseResult.purchased); + expect(viaDefault!.closeReason, PLYCloseReason.button); + }); + + test( + 'display() dismissal uses the local onDismissed and skips the default ' + 'handler when both are set', () async { + PLYPresentationOutcome? viaLocal; + PLYPresentationOutcome? viaDefault; + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + viaDefault = outcome; + }); + + final request = PLYPresentationBuilder.placement('home') + .onDismissed((outcome) => viaLocal = outcome) + .build(); + // ignore: unawaited_futures + request.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onDismissed', + 'requestId': request.requestId, + 'outcome': { + 'purchaseResult': 'cancelled', + 'closeReason': 'button', + }, + }); + + expect(viaLocal, isNotNull); + expect(viaLocal!.purchaseResult, PLYPurchaseResult.cancelled); + // The local handler took precedence; the default handler must NOT fire. + expect(viaDefault, isNull); + }); + + test( + 'await display() returns the outcome to the awaiting caller + local ' + 'onDismissed, and the default handler stays silent', () async { + PLYPresentationOutcome? viaLocal; + PLYPresentationOutcome? viaDefault; + await Purchasely.setDefaultPresentationDismissHandler((outcome) { + viaDefault = outcome; + }); + + final request = PLYPresentationBuilder.placement('home') + .onDismissed((outcome) => viaLocal = outcome) + .build(); + await request.preload(); + calls.clear(); + + final futureOutcome = request.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onDismissed', + 'requestId': request.requestId, + 'outcome': { + 'purchaseResult': 'purchased', + 'closeReason': 'button', + 'plan': {'vendorId': 'monthly'}, + }, + }); + + final outcome = await futureOutcome; + // The awaited display() future resolves with the outcome (local consume). + expect(outcome.purchaseResult, PLYPurchaseResult.purchased); + expect(outcome.closeReason, PLYCloseReason.button); + expect(outcome.plan?.vendorId, 'monthly'); + // The local onDismissed also received it… + expect(viaLocal, isNotNull); + expect(viaLocal!.purchaseResult, PLYPurchaseResult.purchased); + // …and the default handler must NOT fire. + expect(viaDefault, isNull); + }); + + test('re-display() after dismiss resolves the second future', () async { + // Regression: after a dismiss the request entry is dropped, so a second + // display() on the same PLYPresentation handle must re-register the entry — + // otherwise its dismiss completer is never stored and the future hangs. + final request = PLYPresentationBuilder.placement('home').build(); + final presentation = await request.preload(); + calls.clear(); + + // First display → dismiss. + // ignore: unawaited_futures + final firstOutcome = presentation.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + await emitEvent({ + 'event': 'onDismissed', + 'requestId': presentation.requestId, + 'outcome': {'purchaseResult': 'cancelled'}, + }); + expect((await firstOutcome).purchaseResult, PLYPurchaseResult.cancelled); + + // Second display on the same handle → dismiss. The future must complete. + // ignore: unawaited_futures + final secondOutcome = presentation.display(const PLYTransition.modal()); + await Future.delayed(Duration.zero); + expect(calls.where((c) => c.method == 'display'), hasLength(2)); + await emitEvent({ + 'event': 'onDismissed', + 'requestId': presentation.requestId, + 'outcome': {'purchaseResult': 'purchased'}, + }); + expect((await secondOutcome).purchaseResult, PLYPurchaseResult.purchased); + }); + + test('onCloseRequested fires the builder callback', () async { + var fired = false; + final request = + PLYPresentationBuilder.placement('home').onCloseRequested(() { + fired = true; + }).build(); + + // ignore: unawaited_futures + request.preload(); + await Future.delayed(Duration.zero); + + await emitEvent({ + 'event': 'onCloseRequested', + 'requestId': request.requestId, + }); + + expect(fired, true); + }); + + test('interceptor lifecycle: register → trigger → resolve', () async { + PLYInterceptorInfo? capturedInfo; + PLYActionPayload? capturedPayload; + await PurchaselyBridge.ensureInstalled().registerInterceptor( + PLYPresentationActionKind.purchase, + (info, payload) async { + capturedInfo = info; + capturedPayload = payload; + return PLYInterceptResult.success; + }, + ); + + // The register call must have hit the MethodChannel. + final registerCall = + calls.firstWhere((c) => c.method == 'registerInterceptor'); + expect((registerCall.arguments as Map)['kind'], 'purchase'); + + // Fire a triggered event from "native". + await emitEvent({ + 'event': 'interceptorTriggered', + 'requestId': 'cb-1', + 'kind': 'purchase', + 'info': {'contentId': 'c1'}, + 'payload': { + 'plan': { + 'vendorId': 'monthly', + 'productId': 'monthly-product', + 'basePlanId': 'monthly-base', + }, + 'subscriptionOffer': { + 'subscriptionId': 'monthly-subscription', + 'basePlanId': 'monthly-base', + 'offerToken': 'intro-token', + 'offerId': 'intro', + }, + 'offer': { + 'vendorId': 'promo', + 'storeOfferId': 'store-promo', + 'publicId': 'public-promo', + }, + }, + }); + + // Let the async handler run. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(capturedInfo, isNotNull); + expect(capturedInfo!.contentId, 'c1'); + expect(capturedPayload, isA()); + final purchase = capturedPayload as PLYPurchasePayload; + expect( + purchase.plan, + isA() + .having((plan) => plan.vendorId, 'vendorId', 'monthly') + .having((plan) => plan.productId, 'productId', 'monthly-product') + .having((plan) => plan.basePlanId, 'basePlanId', 'monthly-base'), + ); + expect( + purchase.subscriptionOffer, + isA() + .having((offer) => offer.subscriptionId, 'subscriptionId', + 'monthly-subscription') + .having((offer) => offer.basePlanId, 'basePlanId', 'monthly-base') + .having((offer) => offer.offerToken, 'offerToken', 'intro-token') + .having((offer) => offer.offerId, 'offerId', 'intro'), + ); + expect( + purchase.offer, + isA() + .having((offer) => offer.vendorId, 'vendorId', 'promo') + .having( + (offer) => offer.storeOfferId, 'storeOfferId', 'store-promo') + .having((offer) => offer.publicId, 'publicId', 'public-promo'), + ); + + // The bridge must have posted the result back via interceptorResolve. + final resolveCall = + calls.firstWhere((c) => c.method == 'interceptorResolve'); + final args = resolveCall.arguments as Map; + expect(args['invocationId'], 'cb-1'); + expect(args['result'], 'success'); + }); + + test('removeActionInterceptor unregisters the kind on the native side', + () async { + await PurchaselyBridge.ensureInstalled().registerInterceptor( + PLYPresentationActionKind.login, + (_, __) async => PLYInterceptResult.success, + ); + calls.clear(); + + await PurchaselyBridge.ensureInstalled() + .removeActionInterceptor(PLYPresentationActionKind.login); + + // Wire verb stays `removeInterceptor` (native dispatch unchanged). + final removeCall = + calls.firstWhere((c) => c.method == 'removeInterceptor'); + expect((removeCall.arguments as Map)['kind'], 'login'); + }); + + test('removeAllActionInterceptors clears all on the native side', () async { + await PurchaselyBridge.ensureInstalled().registerInterceptor( + PLYPresentationActionKind.purchase, + (_, __) async => PLYInterceptResult.success, + ); + calls.clear(); + + await PurchaselyBridge.ensureInstalled().removeAllActionInterceptors(); + + // Wire verb stays `removeAllInterceptors` (native dispatch unchanged). + expect( + calls.where((c) => c.method == 'removeAllInterceptors'), + hasLength(1), + ); + }); + + test('Purchasely.interceptAction registers via the same channel call', + () async { + await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (_, __) async => PLYInterceptResult.notHandled, + ); + + final registerCall = + calls.firstWhere((c) => c.method == 'registerInterceptor'); + expect((registerCall.arguments as Map)['kind'], 'navigate'); + }); + + test('start() forwards the exact wire contract the native side reads', + () async { + // Guards the MethodChannel `start` payload. This regressed before and + // was not caught because tests mocked start→true without asserting args. + final ok = await Purchasely.apiKey('K') + .appUserId('U') + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.warn) + .allowDeeplink(true) + .allowCampaigns(false) + .stores([PLYStore.google]).start(); + expect(ok, isTrue); + + final startCall = calls.firstWhere((c) => c.method == 'start'); + final args = startCall.arguments as Map; + expect(args['apiKey'], 'K'); + expect(args['appUserId'], 'U'); + expect(args['runningMode'], 'full'); + expect(args['logLevel'], 'warn'); + expect(args['stores'], ['google']); + // The native side also reads these keys when the builder sets them. + expect(args['allowDeeplink'], true); + expect(args['allowCampaigns'], false); + expect(args.containsKey('storekitVersion'), isTrue); + }); + }); +} diff --git a/purchasely/test/native_view_widget_test.dart b/purchasely/test/native_view_widget_test.dart index eabad48e..5420b635 100644 --- a/purchasely/test/native_view_widget_test.dart +++ b/purchasely/test/native_view_widget_test.dart @@ -9,208 +9,70 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('PLYPresentationView', () { - test('creates instance with all parameters', () { - final presentation = PLYPresentation( - 'pres-123', - 'placement-123', - 'audience-123', - 'abtest-123', - 'variant-A', - 'en', - 600, - PLYPresentationType.normal, [], {}); - - final view = PLYPresentationView( - presentation: presentation, - placementId: 'placement-456', - presentationId: 'presentation-789', - contentId: 'content-123', - callback: (result) {}, - ); - - expect(view.presentation, presentation); - expect(view.placementId, 'placement-456'); - expect(view.presentationId, 'presentation-789'); - expect(view.contentId, 'content-123'); - expect(view.callback, isNotNull); - }); - - test('creates instance with minimal parameters', () { - final view = PLYPresentationView(); - - expect(view.presentation, isNull); - expect(view.placementId, isNull); - expect(view.presentationId, isNull); - expect(view.contentId, isNull); - expect(view.callback, isNull); - }); - - test('has correct channel name', () { - final view = PLYPresentationView(); - - expect(view.channel, isA()); - }); - - test('has correct view type', () { - final view = PLYPresentationView(); - - expect(view.viewType, 'io.purchasely.purchasely_flutter/native_view'); - }); - - test('creates instance with only presentation', () { - final presentation = PLYPresentation( - 'pres-123', - 'placement-123', - null, - null, - null, - 'en', - 400, - PLYPresentationType.fallback, - [PLYPresentationPlan('plan-123', 'product-123', null, null)], - {'theme': 'dark'}); - - final view = PLYPresentationView(presentation: presentation); - - expect(view.presentation!.id, 'pres-123'); - expect(view.presentation!.type, PLYPresentationType.fallback); - }); - - test('creates instance with only placementId', () { - final view = PLYPresentationView(placementId: 'placement-only'); - - expect(view.placementId, 'placement-only'); - expect(view.presentation, isNull); - }); - - test('creates instance with only presentationId', () { - final view = PLYPresentationView(presentationId: 'presentation-only'); - - expect(view.presentationId, 'presentation-only'); - expect(view.presentation, isNull); - }); - - test('creates instance with only contentId', () { - final view = PLYPresentationView(contentId: 'content-only'); - - expect(view.contentId, 'content-only'); - expect(view.presentation, isNull); - }); - - test('creates instance with only callback', () { - bool callbackCalled = false; - final view = PLYPresentationView( - callback: (result) { - callbackCalled = true; - }, - ); - - expect(view.callback, isNotNull); - // Invoke the callback to test it works - view.callback!( - PresentPresentationResult(PLYPurchaseResult.purchased, null)); - expect(callbackCalled, true); - }); - - test('callback receives correct result', () { - PresentPresentationResult? receivedResult; - final plan = PLYPlan( - 'plan-123', - 'product-123', - 'Premium', - PLYPlanType.autoRenewingSubscription, - 9.99, - '\$9.99', - 'USD', - '\$', - '9.99', - 'P1M', - false, - null, - null, - null, - null, - false); - - final view = PLYPresentationView( - callback: (result) { - receivedResult = result; + const methodChannelName = 'purchasely'; + const eventChannelName = 'purchasely-presentation-events'; + late TestDefaultBinaryMessenger messenger; + + setUp(() { + messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler( + const MethodChannel(methodChannelName), + (call) async { + switch (call.method) { + case 'preload': + return { + 'screenId': 'screen_42', + 'placementId': (call.arguments as Map?)?['source']?['id'], + 'height': 600, + 'type': 0, + 'plans': >[], + }; + default: + return null; + } }, ); + messenger.setMockMessageHandler( + eventChannelName, (message) async => null); - final expectedResult = - PresentPresentationResult(PLYPurchaseResult.restored, plan); - view.callback!(expectedResult); - - expect(receivedResult, isNotNull); - expect(receivedResult!.result, PLYPurchaseResult.restored); - expect(receivedResult!.plan!.vendorId, 'plan-123'); + PurchaselyBridge.debugReset(); + PurchaselyBridge.ensureInstalled(); }); - testWidgets('build returns Text for unsupported platform', - (WidgetTester tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.windows; - - final view = PLYPresentationView( - placementId: 'test-placement', - ); - - await tester.pumpWidget(MaterialApp(home: Scaffold(body: view))); - - expect(find.textContaining('is not supported yet'), findsOneWidget); - - debugDefaultTargetPlatformOverride = null; + tearDown(() { + PurchaselyBridge.debugReset(); + messenger.setMockMethodCallHandler( + const MethodChannel(methodChannelName), null); + messenger.setMockMessageHandler(eventChannelName, null); }); - testWidgets('build returns Text for Linux platform', - (WidgetTester tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.linux; - - final view = PLYPresentationView( - placementId: 'test-placement', - ); - - await tester.pumpWidget(MaterialApp(home: Scaffold(body: view))); - - expect(find.textContaining('is not supported yet'), findsOneWidget); - - debugDefaultTargetPlatformOverride = null; + test('has correct view type', () { + expect(PLYPresentationView.viewType, + 'io.purchasely.purchasely_flutter/native_view'); }); - testWidgets('build returns Text for Fuchsia platform', + testWidgets('shows a loading indicator before preload resolves', (WidgetTester tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia; - - final view = PLYPresentationView( - placementId: 'test-placement', - ); + final request = PLYPresentationBuilder.placement('home').build(); + final view = PLYPresentationView(request: request); await tester.pumpWidget(MaterialApp(home: Scaffold(body: view))); - expect(find.textContaining('is not supported yet'), findsOneWidget); - - debugDefaultTargetPlatformOverride = null; - }); - - test('view type is consistent', () { - final view1 = PLYPresentationView(); - final view2 = PLYPresentationView(placementId: 'test'); - - expect(view1.viewType, view2.viewType); + // First frame: preload future not yet resolved. + expect(find.byType(CircularProgressIndicator), findsOneWidget); }); - }); - group('PLYPresentationView layout direction', () { - testWidgets('Android view uses inherited text direction', + testWidgets( + 'renders a hybrid-composition PlatformViewLink after preload (Android)', (WidgetTester tester) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.android; try { - final view = PLYPresentationView( - placementId: 'test-placement', - ); + final request = PLYPresentationBuilder.placement('home').build(); + final view = PLYPresentationView(request: request); - // LTR context await tester.pumpWidget( MaterialApp( home: Directionality( @@ -219,101 +81,55 @@ void main() { ), ), ); - - expect( - tester.widget(find.byType(AndroidView)).layoutDirection, - TextDirection.ltr, - ); - - // RTL context - await tester.pumpWidget( - MaterialApp( - home: Directionality( - textDirection: TextDirection.rtl, - child: Scaffold(body: view), - ), - ), - ); - - expect( - tester.widget(find.byType(AndroidView)).layoutDirection, - TextDirection.rtl, - ); + // Let the preload future resolve, then rebuild. Avoid pumpAndSettle: + // the hybrid-composition platform view never "settles" under test. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // The Android branch uses hybrid composition (PlatformViewLink + + // initExpensiveAndroidView) so the embedded native paywall receives + // touch events — a plain virtual-display AndroidView does not. + final link = + tester.widget(find.byType(PlatformViewLink)); + expect(link.viewType, PLYPresentationView.viewType); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } }); - testWidgets('Android view falls back to LTR without Directionality', + testWidgets('renders a UiKitView with the requestId on iOS', (WidgetTester tester) async { final previousPlatform = debugDefaultTargetPlatformOverride; - debugDefaultTargetPlatformOverride = TargetPlatform.android; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; try { - final view = PLYPresentationView(placementId: 'test-placement'); + final request = PLYPresentationBuilder.placement('home').build(); + final view = PLYPresentationView(request: request); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: view, - ), - ); + await tester.pumpWidget(MaterialApp(home: Scaffold(body: view))); + await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(AndroidView)).layoutDirection, - TextDirection.ltr, - ); + final uiKitView = tester.widget(find.byType(UiKitView)); + expect(uiKitView.viewType, PLYPresentationView.viewType); + final params = uiKitView.creationParams as Map; + expect(params['requestId'], request.requestId); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } }); - }); - - group('PLYPresentationView Integration with Purchasely', () { - test('getPresentationView creates valid PLYPresentationView', () { - final view = Purchasely.getPresentationView( - placementId: 'placement-123', - presentationId: 'presentation-456', - contentId: 'content-789', - callback: (result) {}, - ); - - expect(view, isNotNull); - expect(view, isA()); - expect(view!.placementId, 'placement-123'); - expect(view.presentationId, 'presentation-456'); - expect(view.contentId, 'content-789'); - }); - test('getPresentationView with presentation parameter', () { - final presentation = PLYPresentation( - 'pres-123', - 'placement-123', - 'audience-123', - 'abtest-123', - 'variant-A', - 'en', - 600, - PLYPresentationType.normal, [], {}); + testWidgets('build returns Text for unsupported platform', + (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; - final view = Purchasely.getPresentationView( - presentation: presentation, - callback: (result) {}, - ); + final request = PLYPresentationBuilder.placement('home').build(); + final view = PLYPresentationView(request: request); - expect(view, isNotNull); - expect(view!.presentation, presentation); - expect(view.presentation!.id, 'pres-123'); - }); + await tester.pumpWidget(MaterialApp(home: Scaffold(body: view))); + await tester.pumpAndSettle(); - test('getPresentationView with null parameters returns view', () { - final view = Purchasely.getPresentationView(); + expect(find.textContaining('is not supported yet'), findsOneWidget); - expect(view, isNotNull); - expect(view!.presentation, isNull); - expect(view.placementId, isNull); - expect(view.presentationId, isNull); - expect(view.contentId, isNull); - expect(view.callback, isNull); + debugDefaultTargetPlatformOverride = null; }); }); } diff --git a/purchasely/test/platform_channel_test.dart b/purchasely/test/platform_channel_test.dart index 1cdacaf9..8cbf9c5d 100644 --- a/purchasely/test/platform_channel_test.dart +++ b/purchasely/test/platform_channel_test.dart @@ -28,44 +28,47 @@ void main() { }); group('SDK Initialization', () { - test('start sends correct parameters to native', () async { - await Purchasely.start( - apiKey: 'test-api-key', - androidStores: ['Google'], - storeKit1: false, - logLevel: PLYLogLevel.debug, - userId: 'user-123', - runningMode: PLYRunningMode.full, - ); + test('synchronize sends method call to native', () async { + await Purchasely.synchronize(); - expect(methodCalls.length, 1); - expect(methodCalls.first.method, 'start'); - expect(methodCalls.first.arguments['apiKey'], 'test-api-key'); - expect(methodCalls.first.arguments['stores'], ['Google']); - expect(methodCalls.first.arguments['storeKit1'], false); - expect(methodCalls.first.arguments['logLevel'], 0); // debug = 0 - expect(methodCalls.first.arguments['userId'], 'user-123'); - expect(methodCalls.first.arguments['runningMode'], 3); // full = 3 + expect(methodCalls.first.method, 'synchronize'); }); - test('start with required parameters only', () async { - await Purchasely.start(apiKey: 'minimal-key', storeKit1: true); - - expect(methodCalls.first.method, 'start'); - expect(methodCalls.first.arguments['apiKey'], 'minimal-key'); - expect(methodCalls.first.arguments['storeKit1'], true); + test('synchronize awaits the native callback (resolves on success)', + () async { + // The 6.0 native SDKs resolve synchronize() through a success/error + // callback. The Dart Future must complete (not hang) once native + // acknowledges. A timeout failure here would catch a regression to the + // old fire-and-forget bridge that never wired the callback. + final ok = + await Purchasely.synchronize().timeout(const Duration(seconds: 1)); + expect(methodCalls.last.method, 'synchronize'); + expect(ok, isTrue); }); - test('close sends method call to native', () async { - await Purchasely.close(); + test('synchronize rethrows a native failure as PlatformException', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + methodCalls.add(methodCall); + if (methodCall.method == 'synchronize') { + throw PlatformException( + code: '-1', message: 'Synchronization failed'); + } + return _handleMethodCall(methodCall); + }); - expect(methodCalls.first.method, 'close'); + expect( + () => Purchasely.synchronize(), + throwsA(isA()), + ); }); - test('synchronize sends method call to native', () async { - await Purchasely.synchronize(); + test('allowCampaigns sends runtime campaign gate to native', () async { + await Purchasely.allowCampaigns(false); - expect(methodCalls.first.method, 'synchronize'); + expect(methodCalls.first.method, 'allowCampaigns'); + expect(methodCalls.first.arguments['allowCampaigns'], false); }); }); @@ -91,78 +94,6 @@ void main() { }); }); - group('Presentation Methods', () { - test('fetchPresentation sends correct placementId', () async { - final presentation = await Purchasely.fetchPresentation('onboarding'); - - expect(methodCalls.first.method, 'fetchPresentation'); - expect(methodCalls.first.arguments['placementVendorId'], 'onboarding'); - expect(presentation, isNotNull); - expect(presentation!.id, 'presentation-123'); - expect(presentation.type, PLYPresentationType.normal); - }); - - test('fetchPresentation with presentationId', () async { - await Purchasely.fetchPresentation('onboarding', - presentationId: 'pres-456'); - - expect(methodCalls.first.arguments['presentationVendorId'], 'pres-456'); - }); - - test('fetchPresentation with contentId', () async { - await Purchasely.fetchPresentation('onboarding', - contentId: 'content-789'); - - expect(methodCalls.first.arguments['contentId'], 'content-789'); - }); - - test('presentPresentationWithIdentifier sends presentationId', () async { - await Purchasely.presentPresentationWithIdentifier('pres-123'); - - expect(methodCalls.first.method, 'presentPresentationWithIdentifier'); - expect(methodCalls.first.arguments['presentationVendorId'], 'pres-123'); - }); - - test('presentPresentationForPlacement sends placementId', () async { - await Purchasely.presentPresentationForPlacement('premium'); - - expect(methodCalls.first.method, 'presentPresentationForPlacement'); - expect(methodCalls.first.arguments['placementVendorId'], 'premium'); - }); - - test('presentProductWithIdentifier sends productId', () async { - await Purchasely.presentProductWithIdentifier('product-123'); - - expect(methodCalls.first.method, 'presentProductWithIdentifier'); - expect(methodCalls.first.arguments['productVendorId'], 'product-123'); - }); - - test('presentPlanWithIdentifier sends planId', () async { - await Purchasely.presentPlanWithIdentifier('plan-123'); - - expect(methodCalls.first.method, 'presentPlanWithIdentifier'); - expect(methodCalls.first.arguments['planVendorId'], 'plan-123'); - }); - - test('closePresentation sends method call to native', () async { - await Purchasely.closePresentation(); - - expect(methodCalls.first.method, 'closePresentation'); - }); - - test('hidePresentation sends method call to native', () async { - await Purchasely.hidePresentation(); - - expect(methodCalls.first.method, 'hidePresentation'); - }); - - test('showPresentation sends method call to native', () async { - await Purchasely.showPresentation(); - - expect(methodCalls.first.method, 'showPresentation'); - }); - }); - group('Product & Plan Methods', () { test('productWithIdentifier returns correct product', () async { final product = @@ -171,7 +102,7 @@ void main() { expect(methodCalls.first.method, 'productWithIdentifier'); expect(methodCalls.first.arguments['vendorId'], 'product-vendor-123'); expect(product, isNotNull); - expect(product!.name, 'Test Product'); + expect(product.name, 'Test Product'); }); test('planWithIdentifier returns correct plan', () async { @@ -235,12 +166,6 @@ void main() { expect(history.first.cumulatedRevenuesInUSD, 29.97); }); - test('presentSubscriptions sends method call to native', () async { - await Purchasely.presentSubscriptions(); - - expect(methodCalls.first.method, 'presentSubscriptions'); - }); - test('displaySubscriptionCancellationInstruction sends method call', () async { await Purchasely.displaySubscriptionCancellationInstruction(); @@ -440,13 +365,20 @@ void main() { expect(methodCalls.first.arguments['language'], 'fr'); }); - test('isDeeplinkHandled returns boolean', () async { - final handled = await Purchasely.isDeeplinkHandled('app://premium'); + test('handleDeeplink returns boolean', () async { + final handled = await Purchasely.handleDeeplink('app://premium'); - expect(methodCalls.first.method, 'isDeeplinkHandled'); + expect(methodCalls.first.method, 'handleDeeplink'); expect(methodCalls.first.arguments['deeplink'], 'app://premium'); expect(handled, true); }); + + test('allowDeeplink sends v6 method name', () async { + await Purchasely.allowDeeplink(true); + + expect(methodCalls.first.method, 'allowDeeplink'); + expect(methodCalls.first.arguments['allowDeeplink'], true); + }); }); group('Attributes', () { @@ -620,21 +552,6 @@ void main() { }); }); - group('Paywall Action Interceptor', () { - test('onProcessAction sends processAction status', () async { - await Purchasely.onProcessAction(true); - - expect(methodCalls.first.method, 'onProcessAction'); - expect(methodCalls.first.arguments['processAction'], true); - }); - - test('onProcessAction with false', () async { - await Purchasely.onProcessAction(false); - - expect(methodCalls.first.arguments['processAction'], false); - }); - }); - group('Privacy & Consent', () { test('setDebugMode sends debugMode status', () async { await Purchasely.setDebugMode(true); @@ -663,10 +580,8 @@ void main() { }); test('PLYRunningMode converts to correct int values', () { - expect(PLYRunningMode.transactionOnly.index, 0); - expect(PLYRunningMode.observer.index, 1); - expect(PLYRunningMode.paywallObserver.index, 2); - expect(PLYRunningMode.full.index, 3); + expect(PLYRunningMode.observer.index, 0); + expect(PLYRunningMode.full.index, 1); }); test('PLYThemeMode converts to correct int values', () { @@ -704,20 +619,6 @@ void main() { expect(PLYPurchaseResult.restored.index, 2); }); - test('PLYPaywallAction converts correctly', () { - expect(PLYPaywallAction.close.index, 0); - expect(PLYPaywallAction.close_all.index, 1); - expect(PLYPaywallAction.login.index, 2); - expect(PLYPaywallAction.navigate.index, 3); - expect(PLYPaywallAction.purchase.index, 4); - expect(PLYPaywallAction.restore.index, 5); - expect(PLYPaywallAction.open_presentation.index, 6); - expect(PLYPaywallAction.open_placement.index, 7); - expect(PLYPaywallAction.promo_code.index, 8); - expect(PLYPaywallAction.open_flow_step.index, 9); - expect(PLYPaywallAction.web_checkout.index, 10); - }); - test('PLYDataProcessingLegalBasis converts correctly', () { expect(PLYDataProcessingLegalBasis.essential.index, 0); expect(PLYDataProcessingLegalBasis.optional.index, 1); @@ -751,47 +652,6 @@ void main() { }); }); - group('Android Plugin Specific Tests', () { - late MethodChannel channel; - final List methodCalls = []; - - setUp(() { - channel = const MethodChannel('purchasely'); - methodCalls.clear(); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - methodCalls.add(methodCall); - return _handleMethodCall(methodCall); - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - test('Android stores parameter is passed correctly', () async { - await Purchasely.start( - apiKey: 'test-key', - androidStores: ['Google', 'Huawei', 'Amazon'], - storeKit1: false, - ); - - expect(methodCalls.first.arguments['stores'], - ['Google', 'Huawei', 'Amazon']); - }); - - test('Android default store is Google', () async { - await Purchasely.start( - apiKey: 'test-key', - storeKit1: false, - ); - - expect(methodCalls.first.arguments['stores'], ['Google']); - }); - }); - group('iOS Plugin Specific Tests', () { late MethodChannel channel; final List methodCalls = []; @@ -812,24 +672,6 @@ void main() { .setMockMethodCallHandler(channel, null); }); - test('storeKit1 parameter is passed correctly as true', () async { - await Purchasely.start( - apiKey: 'test-key', - storeKit1: true, - ); - - expect(methodCalls.first.arguments['storeKit1'], true); - }); - - test('storeKit1 parameter is passed correctly as false', () async { - await Purchasely.start( - apiKey: 'test-key', - storeKit1: false, - ); - - expect(methodCalls.first.arguments['storeKit1'], false); - }); - test('signPromotionalOffer is iOS specific method', () async { final result = await Purchasely.signPromotionalOffer('product-123', 'offer-456'); @@ -846,12 +688,9 @@ void main() { /// Simulates native method call responses for both iOS and Android dynamic _handleMethodCall(MethodCall methodCall) { switch (methodCall.method) { - case 'start': - return true; - case 'close': - return null; case 'synchronize': - return null; + // Native resolves synchronize() success with `true`. + return true; case 'getAnonymousUserId': return 'anonymous-user-123'; case 'userLogin': @@ -866,9 +705,10 @@ dynamic _handleMethodCall(MethodCall methodCall) { return null; case 'setThemeMode': return null; - case 'readyToOpenDeeplink': + case 'allowDeeplink': + case 'allowCampaigns': return null; - case 'isDeeplinkHandled': + case 'handleDeeplink': return true; case 'restoreAllProducts': return true; @@ -878,45 +718,6 @@ dynamic _handleMethodCall(MethodCall methodCall) { return null; case 'revokeDataProcessingConsent': return null; - case 'fetchPresentation': - return { - 'id': 'presentation-123', - 'placementId': 'placement-456', - 'audienceId': 'audience-789', - 'abTestId': 'abtest-001', - 'abTestVariantId': 'variant-A', - 'language': 'en', - 'type': 0, - 'plans': [ - { - 'planVendorId': 'plan-123', - 'storeProductId': 'product-123', - } - ], - 'metadata': {'key': 'value'} - }; - case 'presentPresentation': - case 'presentPresentationWithIdentifier': - case 'presentPresentationForPlacement': - case 'presentProductWithIdentifier': - case 'presentPlanWithIdentifier': - return { - 'result': 0, - 'plan': { - 'vendorId': 'plan-vendor-123', - 'productId': 'product-123', - 'name': 'Premium Plan', - 'type': 2, - 'amount': 9.99, - } - }; - case 'closePresentation': - case 'hidePresentation': - case 'showPresentation': - return null; - case 'clientPresentationDisplayed': - case 'clientPresentationClosed': - return null; case 'productWithIdentifier': final vendorId = methodCall.arguments['vendorId']; if (vendorId == 'non-existent') { @@ -960,8 +761,6 @@ dynamic _handleMethodCall(MethodCall methodCall) { 'subscriptionDurationInMonths': 3, } ]; - case 'presentSubscriptions': - return null; case 'displaySubscriptionCancellationInstruction': return null; case 'userDidConsumeSubscriptionContent': @@ -1015,12 +814,6 @@ dynamic _handleMethodCall(MethodCall methodCall) { }; case 'isEligibleForIntroOffer': return true; - case 'setPaywallActionInterceptor': - return null; - case 'onProcessAction': - return null; - case 'setDefaultPresentationResultHandler': - return null; default: return null; } diff --git a/purchasely/test/purchasely_flutter_test.dart b/purchasely/test/purchasely_flutter_test.dart index 6b781d8e..47912cf4 100644 --- a/purchasely/test/purchasely_flutter_test.dart +++ b/purchasely/test/purchasely_flutter_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:purchasely_flutter/purchasely_flutter.dart'; -import 'package:purchasely_flutter/native_view_widget.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -35,54 +34,8 @@ void main() { return true; case 'isEligibleForIntroOffer': return true; - case 'isDeeplinkHandled': + case 'handleDeeplink': return true; - case 'fetchPresentation': - return { - 'id': 'presentation-123', - 'placementId': 'placement-456', - 'audienceId': 'audience-789', - 'abTestId': 'abtest-001', - 'abTestVariantId': 'variant-A', - 'language': 'en', - 'height': 600, - 'type': 0, - 'plans': [ - { - 'planVendorId': 'plan-123', - 'storeProductId': 'product-123', - 'basePlanId': 'base-plan', - 'offerId': 'offer-123' - } - ], - 'metadata': {'key': 'value'} - }; - case 'presentPresentation': - case 'presentPresentationWithIdentifier': - case 'presentPresentationForPlacement': - case 'presentProductWithIdentifier': - case 'presentPlanWithIdentifier': - return { - 'result': 0, - 'plan': { - 'vendorId': 'plan-vendor-123', - 'productId': 'product-123', - 'name': 'Premium Plan', - 'type': 2, - 'amount': 9.99, - 'localizedAmount': '\$9.99', - 'currencyCode': 'USD', - 'currencySymbol': '\$', - 'price': '9.99', - 'period': 'P1M', - 'hasIntroductoryPrice': true, - 'introPrice': '\$4.99', - 'introAmount': 4.99, - 'introDuration': 'P1W', - 'introPeriod': 'week', - 'hasFreeTrial': false - } - }; case 'productWithIdentifier': return { 'name': 'Test Product', @@ -204,68 +157,6 @@ void main() { return 'test-value'; case 'userAttributes': return {'attr1': 'value1', 'attr2': 'value2'}; - case 'setDefaultPresentationResultHandler': - return { - 'result': 0, - 'plan': { - 'vendorId': 'plan-vendor-123', - 'productId': 'product-123', - 'name': 'Premium Plan', - 'type': 2, - 'amount': 9.99, - 'localizedAmount': '\$9.99', - 'currencyCode': 'USD', - 'currencySymbol': '\$', - 'price': '9.99', - 'period': 'P1M', - 'hasIntroductoryPrice': false, - 'introPrice': null, - 'introAmount': null, - 'introDuration': null, - 'introPeriod': null, - 'hasFreeTrial': false - } - }; - case 'setPaywallActionInterceptor': - return { - 'info': { - 'contentId': 'content-123', - 'presentationId': 'presentation-123', - 'placementId': 'placement-123', - 'abTestId': 'abtest-123', - 'abTestVariantId': 'variant-A' - }, - 'action': 'purchase', - 'parameters': { - 'url': 'https://example.com', - 'title': 'Test Title', - 'plan': { - 'vendorId': 'plan-vendor-123', - 'productId': 'product-123', - 'name': 'Premium Plan', - 'type': 2, - 'amount': 9.99, - 'localizedAmount': '\$9.99', - 'currencyCode': 'USD', - 'currencySymbol': '\$', - 'price': '9.99', - 'period': 'P1M', - 'hasIntroductoryPrice': false, - 'introPrice': null, - 'introAmount': null, - 'introDuration': null, - 'introPeriod': null, - 'hasFreeTrial': false - }, - 'offer': null, - 'subscriptionOffer': null, - 'presentation': 'presentation-456', - 'clientReferenceId': 'ref-123', - 'webCheckoutProvider': 'stripe', - 'queryParameterKey': 'session_id', - 'closeReason': null - } - }; case 'setDynamicOffering': return true; case 'getDynamicOfferings': @@ -292,27 +183,6 @@ void main() { .setMockMethodCallHandler(channel, null); }); - test('start calls native method with correct parameters', () async { - final result = await Purchasely.start( - apiKey: 'test-api-key', - androidStores: ['Google'], - storeKit1: false, - userId: 'user-123', - logLevel: PLYLogLevel.debug, - runningMode: PLYRunningMode.full, - ); - - expect(result, true); - expect(methodCalls.length, 1); - expect(methodCalls.first.method, 'start'); - expect(methodCalls.first.arguments['apiKey'], 'test-api-key'); - expect(methodCalls.first.arguments['stores'], ['Google']); - expect(methodCalls.first.arguments['storeKit1'], false); - expect(methodCalls.first.arguments['userId'], 'user-123'); - expect(methodCalls.first.arguments['logLevel'], 0); - expect(methodCalls.first.arguments['runningMode'], 3); - }); - test('anonymousUserId returns correct value', () async { final id = await Purchasely.anonymousUserId; expect(id, 'anonymous-user-123'); @@ -351,50 +221,15 @@ void main() { expect(methodCalls.first.arguments['planVendorId'], 'plan-123'); }); - test('isDeeplinkHandled calls native method correctly', () async { + test('handleDeeplink calls native method correctly', () async { final result = - await Purchasely.isDeeplinkHandled('https://example.com/deep'); + await Purchasely.handleDeeplink('https://example.com/deep'); expect(result, true); + expect(methodCalls.first.method, 'handleDeeplink'); expect( methodCalls.first.arguments['deeplink'], 'https://example.com/deep'); }); - test('fetchPresentation returns correct PLYPresentation', () async { - final result = await Purchasely.fetchPresentation('placement-123', - presentationId: 'presentation-456', contentId: 'content-789'); - - expect(result, isNotNull); - expect(result!.id, 'presentation-123'); - expect(result.placementId, 'placement-456'); - expect(result.audienceId, 'audience-789'); - expect(result.abTestId, 'abtest-001'); - expect(result.abTestVariantId, 'variant-A'); - expect(result.language, 'en'); - expect(result.height, 600); - expect(result.type, PLYPresentationType.normal); - expect(result.plans!.length, 1); - expect(result.metadata['key'], 'value'); - }); - - test('presentPresentation returns correct result', () async { - final presentation = PLYPresentation( - 'test-id', - 'placement-id', - 'audience-id', - 'abtest-id', - 'variant-id', - 'en', - 500, - PLYPresentationType.normal, [], {}); - - final result = await Purchasely.presentPresentation(presentation, - isFullscreen: true); - - expect(result.result, PLYPurchaseResult.purchased); - expect(result.plan, isNotNull); - expect(result.plan!.vendorId, 'plan-vendor-123'); - }); - test('productWithIdentifier returns correct product', () async { final product = await Purchasely.productWithIdentifier('vendor-123'); @@ -619,13 +454,6 @@ void main() { expect(methodCalls.first.arguments['mode'], 1); }); - test('onProcessAction calls native method correctly', () async { - await Purchasely.onProcessAction(true); - - expect(methodCalls.first.method, 'onProcessAction'); - expect(methodCalls.first.arguments['processAction'], true); - }); - test('setLanguage calls native method correctly', () async { await Purchasely.setLanguage('fr'); @@ -633,11 +461,11 @@ void main() { expect(methodCalls.first.arguments['language'], 'fr'); }); - test('readyToOpenDeeplink calls native method correctly', () async { - await Purchasely.readyToOpenDeeplink(true); + test('allowDeeplink calls native method correctly', () async { + await Purchasely.allowDeeplink(true); - expect(methodCalls.first.method, 'readyToOpenDeeplink'); - expect(methodCalls.first.arguments['readyToOpenDeeplink'], true); + expect(methodCalls.first.method, 'allowDeeplink'); + expect(methodCalls.first.arguments['allowDeeplink'], true); }); test('setDebugMode calls native method correctly', () async { @@ -682,6 +510,7 @@ void main() { final planMap = { 'vendorId': 'vendor-123', 'productId': 'product-123', + 'basePlanId': 'base-plan-123', 'name': 'Test Plan', 'type': 2, 'amount': 9.99, @@ -703,6 +532,7 @@ void main() { expect(plan, isNotNull); expect(plan!.vendorId, 'vendor-123'); expect(plan.productId, 'product-123'); + expect(plan.basePlanId, 'base-plan-123'); expect(plan.name, 'Test Plan'); expect(plan.type, PLYPlanType.autoRenewingSubscription); expect(plan.amount, 9.99); @@ -719,6 +549,43 @@ void main() { expect(plan.hasFreeTrial, true); }); + test('transformToPLYPlan maps Android v6 offer fields', () { + final planMap = { + 'vendorId': 'vendor-123', + 'productId': 'product-123', + 'name': 'Test Plan', + 'type': 'RENEWING_SUBSCRIPTION', + 'amount': 9.99, + 'localizedAmount': '\$9.99', + 'currencyCode': 'USD', + 'currencySymbol': '\$', + 'price': '\$9.99 / month', + 'period': 'month', + 'hasOfferPrice': true, + 'offerPrice': '\$4.99', + 'offerAmount': 4.99, + 'offerDuration': '7 days', + 'offerPeriod': 'week', + 'hasFreeTrial': true + }; + + final plan = Purchasely.transformToPLYPlan(planMap); + + expect(plan, isNotNull); + expect(plan!.type, PLYPlanType.autoRenewingSubscription); + expect(plan.hasOfferPrice, true); + expect(plan.offerPrice, '\$4.99'); + expect(plan.offerAmount, 4.99); + expect(plan.offerDuration, '7 days'); + expect(plan.offerPeriod, 'week'); + // Deprecated v5 field names stay populated for source compatibility. + expect(plan.hasIntroductoryPrice, true); + expect(plan.introPrice, '\$4.99'); + expect(plan.introAmount, 4.99); + expect(plan.introDuration, '7 days'); + expect(plan.introPeriod, 'week'); + }); + test('transformToPLYPlan handles invalid type gracefully', () { final planMap = { 'vendorId': 'vendor-123', @@ -752,7 +619,8 @@ void main() { test('transformToPLYPromoOffer returns correct offer', () { final offerMap = { 'vendorId': 'offer-vendor-123', - 'storeOfferId': 'store-offer-123' + 'storeOfferId': 'store-offer-123', + 'publicId': 'public-offer-123', }; final offer = Purchasely.transformToPLYPromoOffer(offerMap); @@ -760,6 +628,7 @@ void main() { expect(offer, isNotNull); expect(offer!.vendorId, 'offer-vendor-123'); expect(offer.storeOfferId, 'store-offer-123'); + expect(offer.publicId, 'public-offer-123'); }); test('transformToPLYSubscription returns null for empty map', () { @@ -785,100 +654,6 @@ void main() { expect(subscription.offerId, 'offer-123'); }); - test('transformToPLYPresentation returns null for empty map', () { - final result = Purchasely.transformToPLYPresentation({}); - expect(result, isNull); - }); - - test('transformToPLYPresentation returns correct presentation', () { - final presentationMap = { - 'id': 'pres-123', - 'placementId': 'placement-123', - 'audienceId': 'audience-123', - 'abTestId': 'abtest-123', - 'abTestVariantId': 'variant-A', - 'language': 'en', - 'height': 800, - 'type': 1, - 'plans': [ - { - 'planVendorId': 'plan-123', - 'storeProductId': 'product-123', - 'basePlanId': 'base-123', - 'offerId': 'offer-123' - } - ], - 'metadata': {'theme': 'dark', 'version': '2.0'} - }; - - final presentation = - Purchasely.transformToPLYPresentation(presentationMap); - - expect(presentation, isNotNull); - expect(presentation!.id, 'pres-123'); - expect(presentation.placementId, 'placement-123'); - expect(presentation.audienceId, 'audience-123'); - expect(presentation.abTestId, 'abtest-123'); - expect(presentation.abTestVariantId, 'variant-A'); - expect(presentation.language, 'en'); - expect(presentation.height, 800); - expect(presentation.type, PLYPresentationType.fallback); - expect(presentation.plans!.length, 1); - expect(presentation.plans![0].planVendorId, 'plan-123'); - expect(presentation.metadata['theme'], 'dark'); - }); - - test('transformToPLYPresentation uses default height when null', () { - final presentationMap = { - 'id': 'pres-123', - 'placementId': 'placement-123', - 'audienceId': null, - 'abTestId': null, - 'abTestVariantId': null, - 'language': 'en', - 'height': null, - 'type': 0, - 'plans': [], - 'metadata': null - }; - - final presentation = - Purchasely.transformToPLYPresentation(presentationMap); - - expect(presentation!.height, 0); - }); - - test('transformPLYPresentationToMap returns correct map', () { - final presentation = PLYPresentation( - 'pres-123', - 'placement-123', - 'audience-123', - 'abtest-123', - 'variant-A', - 'en', - 600, - PLYPresentationType.normal, - [PLYPresentationPlan('plan-123', 'product-123', 'base-123', null)], - {'key': 'value'}); - - final map = Purchasely.transformPLYPresentationToMap(presentation); - - expect(map['id'], 'pres-123'); - expect(map['placementId'], 'placement-123'); - expect(map['audienceId'], 'audience-123'); - expect(map['abTestId'], 'abtest-123'); - expect(map['abTestVariantId'], 'variant-A'); - expect(map['language'], 'en'); - expect(map['type'], 0); - }); - - test('transformPLYPresentationToMap handles null presentation', () { - final map = Purchasely.transformPLYPresentationToMap(null); - - expect(map['id'], isNull); - expect(map['placementId'], isNull); - }); - test('transformToDynamicOfferings returns empty list for null input', () { final result = Purchasely.transformToDynamicOfferings(null); expect(result, isEmpty); @@ -1076,40 +851,6 @@ void main() { }); }); - group('PLYPresentationView', () { - test('getPresentationView returns PLYPresentationView', () { - final view = Purchasely.getPresentationView( - placementId: 'placement-123', - presentationId: 'presentation-123', - contentId: 'content-123', - callback: (result) {}, - ); - - expect(view, isNotNull); - expect(view, isA()); - }); - - test('getPresentationView with presentation parameter', () { - final presentation = PLYPresentation( - 'pres-123', - 'placement-123', - 'audience-123', - 'abtest-123', - 'variant-A', - 'en', - 600, - PLYPresentationType.normal, [], {}); - - final view = Purchasely.getPresentationView( - presentation: presentation, - callback: (result) {}, - ); - - expect(view, isNotNull); - expect(view!.presentation, presentation); - }); - }); - group('Model Classes', () { group('PLYPlan', () { test('creates instance with all properties', () { @@ -1142,10 +883,12 @@ void main() { group('PLYPromoOffer', () { test('creates instance with properties', () { - final offer = PLYPromoOffer('vendor-123', 'store-offer-123'); + final offer = + PLYPromoOffer('vendor-123', 'store-offer-123', 'public-offer-123'); expect(offer.vendorId, 'vendor-123'); expect(offer.storeOfferId, 'store-offer-123'); + expect(offer.publicId, 'public-offer-123'); }); }); @@ -1194,7 +937,10 @@ void main() { group('PLYPresentationPlan', () { test('creates instance and converts to map', () { final plan = PLYPresentationPlan( - 'plan-123', 'product-123', 'base-123', 'offer-123'); + planVendorId: 'plan-123', + storeProductId: 'product-123', + basePlanId: 'base-123', + offerId: 'offer-123'); final map = plan.toMap(); @@ -1205,35 +951,6 @@ void main() { }); }); - group('PLYPresentation', () { - test('creates instance and converts to map', () { - final plans = [ - PLYPresentationPlan('plan-123', 'product-123', 'base-123', null) - ]; - final presentation = PLYPresentation( - 'pres-123', - 'placement-123', - 'audience-123', - 'abtest-123', - 'variant-A', - 'en', - 600, - PLYPresentationType.normal, - plans, - {'key': 'value'}); - - final map = presentation.toMap(); - - expect(map['id'], 'pres-123'); - expect(map['placementId'], 'placement-123'); - expect(map['language'], 'en'); - expect(map['height'], 600); - expect(map['type'], 'PLYPresentationType.normal'); - expect(map['plans'].length, 1); - expect(map['metadata']['key'], 'value'); - }); - }); - group('PLYSubscription', () { test('creates instance with all properties', () { final plan = PLYPlan( @@ -1282,119 +999,6 @@ void main() { }); }); - group('PresentPresentationResult', () { - test('creates instance with result and plan', () { - final plan = PLYPlan( - 'plan-123', - 'product-123', - 'Premium', - PLYPlanType.autoRenewingSubscription, - 9.99, - '\$9.99', - 'USD', - '\$', - '9.99', - 'P1M', - false, - null, - null, - null, - null, - false); - - final result = - PresentPresentationResult(PLYPurchaseResult.purchased, plan); - - expect(result.result, PLYPurchaseResult.purchased); - expect(result.plan!.vendorId, 'plan-123'); - }); - - test('creates instance with null plan', () { - final result = - PresentPresentationResult(PLYPurchaseResult.cancelled, null); - - expect(result.result, PLYPurchaseResult.cancelled); - expect(result.plan, isNull); - }); - }); - - group('PaywallActionInterceptorResult', () { - test('creates instance with all properties', () { - final info = PLYPaywallInfo('content-123', 'presentation-123', - 'placement-123', 'abtest-123', 'variant-A'); - final params = PLYPaywallActionParameters( - url: 'https://example.com', title: 'Test Title'); - - final result = PaywallActionInterceptorResult( - info, PLYPaywallAction.purchase, params); - - expect(result.info.contentId, 'content-123'); - expect(result.action, PLYPaywallAction.purchase); - expect(result.parameters.url, 'https://example.com'); - }); - }); - - group('PLYPaywallActionParameters', () { - test('creates instance with all optional properties', () { - final plan = PLYPlan( - 'plan-123', - 'product-123', - 'Premium', - PLYPlanType.autoRenewingSubscription, - 9.99, - '\$9.99', - 'USD', - '\$', - '9.99', - 'P1M', - false, - null, - null, - null, - null, - false); - final offer = PLYPromoOffer('offer-vendor', 'store-offer'); - final subOffer = - PLYSubscriptionOffer('sub-123', 'base-123', 'token', 'offer'); - - final params = PLYPaywallActionParameters( - url: 'https://example.com', - title: 'Test Title', - plan: plan, - offer: offer, - subscriptionOffer: subOffer, - presentation: 'pres-123', - clientReferenceId: 'ref-123', - queryParameterKey: 'session_id', - webCheckoutProvider: 'stripe', - closeReason: 'user_action'); - - expect(params.url, 'https://example.com'); - expect(params.title, 'Test Title'); - expect(params.plan!.vendorId, 'plan-123'); - expect(params.offer!.vendorId, 'offer-vendor'); - expect(params.subscriptionOffer!.subscriptionId, 'sub-123'); - expect(params.presentation, 'pres-123'); - expect(params.clientReferenceId, 'ref-123'); - expect(params.queryParameterKey, 'session_id'); - expect(params.webCheckoutProvider, 'stripe'); - expect(params.closeReason, 'user_action'); - }); - }); - - group('PLYPaywallInfo', () { - test('creates instance with all properties', () { - final info = PLYPaywallInfo('content-123', 'presentation-123', - 'placement-123', 'abtest-123', 'variant-A'); - - expect(info.contentId, 'content-123'); - expect(info.presentationId, 'presentation-123'); - expect(info.placementId, 'placement-123'); - expect(info.abTestId, 'abtest-123'); - expect(info.abTestVariantId, 'variant-A'); - }); - }); - group('PLYEventPropertyPlan', () { test('creates instance with all properties', () { final plan = PLYEventPropertyPlan( @@ -1530,10 +1134,8 @@ void main() { }); test('PLYRunningMode has correct values', () { - expect(PLYRunningMode.transactionOnly.index, 0); - expect(PLYRunningMode.observer.index, 1); - expect(PLYRunningMode.paywallObserver.index, 2); - expect(PLYRunningMode.full.index, 3); + expect(PLYRunningMode.observer.index, 0); + expect(PLYRunningMode.full.index, 1); }); test('PLYThemeMode has correct values', () { @@ -1571,20 +1173,6 @@ void main() { expect(PLYPlanType.unknown.index, 4); }); - test('PLYPaywallAction has all expected values', () { - expect(PLYPaywallAction.close.index, 0); - expect(PLYPaywallAction.close_all.index, 1); - expect(PLYPaywallAction.login.index, 2); - expect(PLYPaywallAction.navigate.index, 3); - expect(PLYPaywallAction.purchase.index, 4); - expect(PLYPaywallAction.restore.index, 5); - expect(PLYPaywallAction.open_presentation.index, 6); - expect(PLYPaywallAction.open_placement.index, 7); - expect(PLYPaywallAction.promo_code.index, 8); - expect(PLYPaywallAction.open_flow_step.index, 9); - expect(PLYPaywallAction.web_checkout.index, 10); - }); - test('PLYAttribute has all expected values', () { expect(PLYAttribute.firebase_app_instance_id.index, 0); expect(PLYAttribute.airship_channel_id.index, 1); @@ -1778,27 +1366,6 @@ void main() { expect(properties.carousels[0].is_carousel_auto_playing, false); }); - test('transformToPLYPresentation handles valid fallback type', () { - final presentationMap = { - 'id': 'pres-123', - 'placementId': 'placement-123', - 'audienceId': null, - 'abTestId': null, - 'abTestVariantId': null, - 'language': 'en', - 'height': 400, - 'type': 1, // Fallback type - 'plans': [], - 'metadata': null - }; - - final presentation = - Purchasely.transformToPLYPresentation(presentationMap); - - expect(presentation, isNotNull); - expect(presentation!.type, PLYPresentationType.fallback); - }); - test('transformToPLYEventProperties handles valid APP_STARTED event name', () { final propertiesMap = { @@ -2093,46 +1660,6 @@ void main() { }); }); - group('Presentation Types Coverage', () { - test('transformToPLYPresentation handles deactivated type', () { - final presentationMap = { - 'id': 'pres-123', - 'placementId': 'placement-123', - 'audienceId': null, - 'abTestId': null, - 'abTestVariantId': null, - 'language': 'en', - 'height': 400, - 'type': 2, - 'plans': [], - 'metadata': {} - }; - - final presentation = - Purchasely.transformToPLYPresentation(presentationMap); - expect(presentation!.type, PLYPresentationType.deactivated); - }); - - test('transformToPLYPresentation handles client type', () { - final presentationMap = { - 'id': 'pres-123', - 'placementId': 'placement-123', - 'audienceId': null, - 'abTestId': null, - 'abTestVariantId': null, - 'language': 'en', - 'height': 400, - 'type': 3, - 'plans': [], - 'metadata': {} - }; - - final presentation = - Purchasely.transformToPLYPresentation(presentationMap); - expect(presentation!.type, PLYPresentationType.client); - }); - }); - group('Subscription Sources Coverage', () { test('all subscription sources are mapped correctly', () { expect(PLYSubscriptionSource.appleAppStore.index, 0); @@ -2173,16 +1700,6 @@ void main() { expect(methodCalls.first.method, 'userLogout'); }); - test('close calls native method', () async { - await Purchasely.close(); - expect(methodCalls.first.method, 'close'); - }); - - test('presentSubscriptions calls native method', () async { - await Purchasely.presentSubscriptions(); - expect(methodCalls.first.method, 'presentSubscriptions'); - }); - test('displaySubscriptionCancellationInstruction calls native method', () async { await Purchasely.displaySubscriptionCancellationInstruction(); @@ -2190,45 +1707,10 @@ void main() { 'displaySubscriptionCancellationInstruction'); }); - test('closePresentation calls native method', () async { - await Purchasely.closePresentation(); - expect(methodCalls.first.method, 'closePresentation'); - }); - - test('hidePresentation calls native method', () async { - await Purchasely.hidePresentation(); - expect(methodCalls.first.method, 'hidePresentation'); - }); - - test('showPresentation calls native method', () async { - await Purchasely.showPresentation(); - expect(methodCalls.first.method, 'showPresentation'); - }); - test('userDidConsumeSubscriptionContent calls native method', () async { await Purchasely.userDidConsumeSubscriptionContent(); expect(methodCalls.first.method, 'userDidConsumeSubscriptionContent'); }); - - test('clientPresentationDisplayed calls native method', () async { - final presentation = PLYPresentation('pres-123', 'placement-123', null, - null, null, 'en', 400, PLYPresentationType.normal, [], {}); - - await Purchasely.clientPresentationDisplayed(presentation); - - expect(methodCalls.first.method, 'clientPresentationDisplayed'); - expect(methodCalls.first.arguments['presentation']['id'], 'pres-123'); - }); - - test('clientPresentationClosed calls native method', () async { - final presentation = PLYPresentation('pres-456', 'placement-456', null, - null, null, 'fr', 500, PLYPresentationType.fallback, [], {}); - - await Purchasely.clientPresentationClosed(presentation); - - expect(methodCalls.first.method, 'clientPresentationClosed'); - expect(methodCalls.first.arguments['presentation']['id'], 'pres-456'); - }); }); group('All PLYAttribute Values', () { @@ -2257,23 +1739,6 @@ void main() { }); }); - group('PLYPaywallAction Coverage', () { - test('all PLYPaywallAction enum values', () { - expect(PLYPaywallAction.values.length, 11); - expect(PLYPaywallAction.close.name, 'close'); - expect(PLYPaywallAction.close_all.name, 'close_all'); - expect(PLYPaywallAction.login.name, 'login'); - expect(PLYPaywallAction.navigate.name, 'navigate'); - expect(PLYPaywallAction.purchase.name, 'purchase'); - expect(PLYPaywallAction.restore.name, 'restore'); - expect(PLYPaywallAction.open_presentation.name, 'open_presentation'); - expect(PLYPaywallAction.open_placement.name, 'open_placement'); - expect(PLYPaywallAction.promo_code.name, 'promo_code'); - expect(PLYPaywallAction.open_flow_step.name, 'open_flow_step'); - expect(PLYPaywallAction.web_checkout.name, 'web_checkout'); - }); - }); - group('Default Parameter Values', () { late MethodChannel channel; final List methodCalls = []; @@ -2373,7 +1838,7 @@ void main() { }); }); - group('Start Method Variations', () { + group('PurchaselyBuilder.start', () { late MethodChannel channel; final List methodCalls = []; @@ -2386,63 +1851,54 @@ void main() { methodCalls.add(methodCall); return true; }); + PurchaselyBridge.debugReset(); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); - }); - - test('start with minimal parameters uses defaults', () async { - await Purchasely.start( - apiKey: 'test-key', - storeKit1: true, - ); - - expect(methodCalls.first.arguments['apiKey'], 'test-key'); - expect(methodCalls.first.arguments['stores'], ['Google']); - expect(methodCalls.first.arguments['storeKit1'], true); - expect(methodCalls.first.arguments['userId'], isNull); - expect(methodCalls.first.arguments['logLevel'], 3); // PLYLogLevel.error - expect( - methodCalls.first.arguments['runningMode'], 3); // PLYRunningMode.full - }); - - test('start with all log levels', () async { - for (final level in PLYLogLevel.values) { - methodCalls.clear(); - await Purchasely.start( - apiKey: 'test-key', - storeKit1: false, - logLevel: level, - ); - - expect(methodCalls.first.arguments['logLevel'], level.index); - } - }); - - test('start with all running modes', () async { - for (final mode in PLYRunningMode.values) { - methodCalls.clear(); - await Purchasely.start( - apiKey: 'test-key', - storeKit1: false, - runningMode: mode, - ); - - expect(methodCalls.first.arguments['runningMode'], mode.index); - } - }); - - test('start with multiple android stores', () async { - await Purchasely.start( - apiKey: 'test-key', - storeKit1: false, - androidStores: ['Google', 'Huawei', 'Amazon'], - ); - - expect(methodCalls.first.arguments['stores'], - ['Google', 'Huawei', 'Amazon']); + PurchaselyBridge.debugReset(); + }); + + test('start with minimal config uses defaults', () async { + final ok = await Purchasely.apiKey('test-key').start(); + + expect(ok, true); + final startCall = methodCalls.firstWhere((c) => c.method == 'start'); + expect(startCall.arguments['apiKey'], 'test-key'); + expect(startCall.arguments['stores'], ['google']); + expect(startCall.arguments['runningMode'], 'observer'); + expect(startCall.arguments['logLevel'], 'error'); + expect(startCall.arguments['storekitVersion'], 'storeKit2'); + expect(startCall.arguments.containsKey('allowCampaigns'), false); + }); + + test('start forwards every modifier', () async { + await Purchasely.apiKey('test-key') + .appUserId('user-123') + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .allowCampaigns(false) + .stores([PLYStore.google, PLYStore.huawei, PLYStore.amazon]) + .storekitVersion(PLYStorekitVersion.storeKit1) + .start(); + + final startCall = methodCalls.firstWhere((c) => c.method == 'start'); + expect(startCall.arguments['appUserId'], 'user-123'); + expect(startCall.arguments['runningMode'], 'full'); + expect(startCall.arguments['logLevel'], 'debug'); + expect(startCall.arguments['allowDeeplink'], true); + expect(startCall.arguments['allowCampaigns'], false); + expect(startCall.arguments['stores'], ['google', 'huawei', 'amazon']); + expect(startCall.arguments['storekitVersion'], 'storeKit1'); + }); + + test('runtime allowCampaigns forwards the campaign gate', () async { + await Purchasely.allowCampaigns(false); + + final call = methodCalls.firstWhere((c) => c.method == 'allowCampaigns'); + expect(call.arguments['allowCampaigns'], false); }); }); } diff --git a/purchasely/test/transition_test.dart b/purchasely/test/transition_test.dart new file mode 100644 index 00000000..66261887 --- /dev/null +++ b/purchasely/test/transition_test.dart @@ -0,0 +1,80 @@ +// Unit tests for `lib/src/transition.dart` — the v6 transition + dimension +// model that replaced the legacy `heightPercentage` field. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +void main() { + group('PLYTransitionDimension', () { + test('percentage serializes type + value', () { + expect( + const PLYTransitionDimension.percentage(0.5).toMap(), + {'type': 'percentage', 'value': 0.5}, + ); + }); + + test('pixel serializes type + value', () { + expect( + const PLYTransitionDimension.pixel(320).toMap(), + {'type': 'pixel', 'value': 320.0}, + ); + }); + + test('generic constructor keeps the explicit type', () { + const dim = + PLYTransitionDimension(type: PLYDimensionType.pixel, value: 12); + expect(dim.type, PLYDimensionType.pixel); + expect(dim.toMap()['type'], 'pixel'); + }); + }); + + group('PLYTransition.toMap', () { + test('modal forwards type + dismissible, omits dimensions', () { + final map = const PLYTransition.modal(dismissible: false).toMap(); + expect(map['type'], 'modal'); + expect(map['dismissible'], false); + expect(map.containsKey('width'), isFalse); + expect(map.containsKey('height'), isFalse); + }); + + test('fullScreen forwards just the type', () { + final map = const PLYTransition.fullScreen().toMap(); + expect(map['type'], 'fullScreen'); + expect(map.containsKey('width'), isFalse); + expect(map.containsKey('height'), isFalse); + }); + + test('popin serializes width + height as dimension maps', () { + final map = const PLYTransition( + type: PLYTransitionType.popin, + width: PLYTransitionDimension.pixel(320), + height: PLYTransitionDimension.percentage(0.5), + dismissible: true, + ).toMap(); + + expect(map['type'], 'popin'); + expect(map['width'], {'type': 'pixel', 'value': 320.0}); + expect( + map['height'], + {'type': 'percentage', 'value': 0.5}, + ); + expect(map['dismissible'], true); + }); + + test('drawer serializes only the provided height', () { + final map = const PLYTransition( + type: PLYTransitionType.drawer, + height: PLYTransitionDimension.percentage(0.6), + ).toMap(); + + expect(map['type'], 'drawer'); + expect( + map['height'], + {'type': 'percentage', 'value': 0.6}, + ); + expect(map.containsKey('width'), isFalse); + // dismissible omitted when not set. + expect(map.containsKey('dismissible'), isFalse); + }); + }); +} diff --git a/purchasely_android_player/CHANGELOG.md b/purchasely_android_player/CHANGELOG.md index 7479ad8a..d38647d9 100644 --- a/purchasely_android_player/CHANGELOG.md +++ b/purchasely_android_player/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.0.0-rc.1 +- Updated Android Purchasely Player SDK to 6.0.0-rc.1. +- Aligns the extension package version with `purchasely_flutter` 6.0.0-rc.1. +- `io.purchasely:player:6.0.0-rc.1` is published on Maven Central; the SDK resolves it from the public repository. + ## 5.7.3 - Updated Android Purchasely Player SDK to 5.7.4. Full changelog available at https://docs.purchasely.com/changelog/57 diff --git a/purchasely_android_player/README.md b/purchasely_android_player/README.md index 9f316eb0..3111b093 100644 --- a/purchasely_android_player/README.md +++ b/purchasely_android_player/README.md @@ -1,52 +1,39 @@ ![Purchasely](images/icon.png) -# Purchasely +# Purchasely Android Player extension -Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery. +Android video player extension for the Purchasely Flutter SDK. Add it when your +presentations contain videos on Android. ## Installation -``` +Use the exact same version for every Purchasely Flutter package: + +```yaml dependencies: - purchasely_flutter: ^5.1.0 + purchasely_flutter: 6.0.0-rc.1 + purchasely_android_player: 6.0.0-rc.1 ``` +This package pulls `io.purchasely:player:6.0.0-rc.1` on Android, published on +Maven Central, so it resolves directly from the public repository. + ## Usage +Initialize and display presentations through the main package v6 API: + ```dart import 'package:purchasely_flutter/purchasely_flutter.dart'; -// ... - -bool configured = await Purchasely.start( - apiKey: '', - androidStores: ['Google, Huawei, Amazon'], - storeKit1: false, - logLevel: PLYLogLevel.error, - runningMode: PLYRunningMode.full, - userId: null, -); - -var result = await Purchasely.presentPresentationForPlacement("", isFullscreen: true); - -switch (result.result) { - case PLYPurchaseResult.cancelled: - { - print("User cancelled purchased"); - } - break; - case PLYPurchaseResult.purchased: - { - print("User purchased ${result.plan?.name}"); - } - break; - case PLYPurchaseResult.restored: - { - print("User restored ${result.plan?.name}"); - } - break; -} +await PurchaselyBuilder.apiKey('') + .runningMode(RunningMode.full) + .stores([PLYStore.google]) + .start(); + +final outcome = await PresentationBuilder.placement('') + .build() + .display(const Transition.fullScreen()); ``` -## 🏁 Documentation -A complete documentation is available on our website [https://docs.purchasely.com](https://docs.purchasely.com) \ No newline at end of file +See the repository `MIGRATION-v6.md` and `sdk_public_doc.md` for the complete v6 +API mapping. diff --git a/purchasely_android_player/android/build.gradle b/purchasely_android_player/android/build.gradle index 5ed0f09c..500343f2 100644 --- a/purchasely_android_player/android/build.gradle +++ b/purchasely_android_player/android/build.gradle @@ -16,6 +16,7 @@ buildscript { rootProject.allprojects { repositories { + mavenLocal() // PURCHASELY v6.0.0 — remove once published to Maven Central google() mavenCentral() } @@ -49,5 +50,5 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - api 'io.purchasely:player:5.7.4' + api 'io.purchasely:player:6.0.0-rc.2' } diff --git a/purchasely_android_player/pubspec.yaml b/purchasely_android_player/pubspec.yaml index 4d9d0cb6..c23bdfee 100644 --- a/purchasely_android_player/pubspec.yaml +++ b/purchasely_android_player/pubspec.yaml @@ -1,6 +1,6 @@ name: purchasely_android_player description: Purchasely Player dependency for Android -version: 5.7.3 +version: 6.0.0-rc.1 homepage: https://www.purchasely.com/ environment: diff --git a/purchasely_google/CHANGELOG.md b/purchasely_google/CHANGELOG.md index 222a9658..f51b74f8 100644 --- a/purchasely_google/CHANGELOG.md +++ b/purchasely_google/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.0.0-rc.1 +- Updated Android Purchasely Google Play SDK to 6.0.0-rc.1. +- Aligns the extension package version with `purchasely_flutter` 6.0.0-rc.1. +- `io.purchasely:google-play:6.0.0-rc.1` is published on Maven Central; the SDK resolves it from the public repository. + ## 5.7.3 - Updated Android Purchasely Google Play SDK to 5.7.4. Full changelog available at https://docs.purchasely.com/changelog/57 diff --git a/purchasely_google/README.md b/purchasely_google/README.md index 9f316eb0..3f489c94 100644 --- a/purchasely_google/README.md +++ b/purchasely_google/README.md @@ -1,52 +1,46 @@ ![Purchasely](images/icon.png) -# Purchasely +# Purchasely Google Play extension -Purchasely is a solution to ease the integration and boost your In-App Purchase & Subscriptions on the App Store, Google Play Store and Huawei App Gallery. +Android Google Play Billing extension for the Purchasely Flutter SDK. ## Installation -``` +Use the exact same version for every Purchasely Flutter package: + +```yaml dependencies: - purchasely_flutter: ^5.1.0 + purchasely_flutter: 6.0.0-rc.1 + purchasely_google: 6.0.0-rc.1 ``` +This package pulls `io.purchasely:google-play:6.0.0-rc.1` on Android, published on +Maven Central, so it resolves directly from the public repository. + ## Usage +Initialize the SDK with the v6 builder and include the Google store: + ```dart import 'package:purchasely_flutter/purchasely_flutter.dart'; -// ... - -bool configured = await Purchasely.start( - apiKey: '', - androidStores: ['Google, Huawei, Amazon'], - storeKit1: false, - logLevel: PLYLogLevel.error, - runningMode: PLYRunningMode.full, - userId: null, -); - -var result = await Purchasely.presentPresentationForPlacement("", isFullscreen: true); - -switch (result.result) { - case PLYPurchaseResult.cancelled: - { - print("User cancelled purchased"); - } - break; - case PLYPurchaseResult.purchased: - { - print("User purchased ${result.plan?.name}"); - } - break; - case PLYPurchaseResult.restored: - { - print("User restored ${result.plan?.name}"); - } - break; +final configured = await PurchaselyBuilder.apiKey('') + .runningMode(RunningMode.full) + .stores([PLYStore.google]) + .start(); +``` + +Display presentations with `PresentationBuilder`: + +```dart +final outcome = await PresentationBuilder.placement('') + .build() + .display(const Transition.fullScreen()); + +if (outcome.purchaseResult == PurchaseResult.purchased) { + print('User purchased ${outcome.plan}'); } ``` -## 🏁 Documentation -A complete documentation is available on our website [https://docs.purchasely.com](https://docs.purchasely.com) \ No newline at end of file +See the repository `MIGRATION-v6.md` and `sdk_public_doc.md` for the complete v6 +API mapping. diff --git a/purchasely_google/android/build.gradle b/purchasely_google/android/build.gradle index c82df54d..0369c9e4 100644 --- a/purchasely_google/android/build.gradle +++ b/purchasely_google/android/build.gradle @@ -16,6 +16,7 @@ buildscript { rootProject.allprojects { repositories { + mavenLocal() // PURCHASELY v6.0.0 — remove once published to Maven Central google() mavenCentral() } @@ -49,5 +50,5 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - api 'io.purchasely:google-play:5.7.4' + api 'io.purchasely:google-play:6.0.0-rc.2' } diff --git a/purchasely_google/pubspec.yaml b/purchasely_google/pubspec.yaml index e532cbe4..a26c2d2b 100644 --- a/purchasely_google/pubspec.yaml +++ b/purchasely_google/pubspec.yaml @@ -1,6 +1,6 @@ name: purchasely_google description: Purchasely Google Play Billing dependency for Android -version: 5.7.3 +version: 6.0.0-rc.1 homepage: https://www.purchasely.com/ environment: @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter plugin_platform_interface: ^2.0.2 - purchasely_flutter: ^5.7.3 + purchasely_flutter: 6.0.0-rc.1 dev_dependencies: flutter_test: diff --git a/sdk_public_doc.md b/sdk_public_doc.md new file mode 100644 index 00000000..d9df8152 --- /dev/null +++ b/sdk_public_doc.md @@ -0,0 +1,824 @@ +# Purchasely Flutter SDK Documentation + +This document provides comprehensive documentation for integrating and using the +Purchasely Flutter SDK with Dart. + +> **Upgrading to 6.0?** This release adapts the plugin to the Purchasely 6.0 +> native SDKs. The paywall surface (start, display / preload / close, action +> interceptor) moved to a fluent builder API documented here; other `Purchasely` +> APIs remain source-compatible. Deeplinks use the v6 names with deprecated +> aliases. See +> [`MIGRATION-v6.md`](./MIGRATION-v6.md) for the complete old→new mapping. The +> Purchasely AI plugin and skills (`purchasely-integrate`, `purchasely-review`, +> `purchasely-debug`) can apply the migration for you. + +A paywall is referred to as a **Presentation** (or *Screen*) throughout this +guide. + +--- + +## Table of Contents + +1. [Requirements](#requirements) +2. [Installation](#installation) +3. [SDK Initialization](#sdk-initialization) +4. [Displaying Presentations](#displaying-presentations) +5. [Processing Transactions](#processing-transactions) +6. [Action Interceptor](#action-interceptor) +7. [User Identification](#user-identification) +8. [Subscription Status & Entitlements](#subscription-status--entitlements) +9. [Custom User Attributes](#custom-user-attributes) +10. [Event Listeners](#event-listeners) +11. [Pre-fetching Screens](#pre-fetching-screens) +12. [Inline Presentations](#inline-presentations) +13. [Deeplinks Management](#deeplinks-management) +14. [Platform-Specific Features](#platform-specific-features) + +--- + +## Requirements + +| Requirement | iOS | Android | +|-------------|-----|---------| +| Minimum OS Version | 13.4 | 23 | +| compileSdkVersion | - | 35 | +| targetSdkVersion | - | 35 | + +--- + +## Installation + +Add the Purchasely Flutter SDK to your `pubspec.yaml`: + +```yaml +dependencies: + purchasely_flutter: 6.0.0-rc.1 +``` + +Then run: + +```shell +flutter pub get +``` + +### Android Dependencies + +With Android, you can choose to use Google Play Store and/or Huawei AppGallery +and/or Amazon Appstore. **You must install the corresponding dependency for each +store you want to support.** + +#### Google Play Billing (Required for Google Play Store) + +If your app is distributed on the **Google Play Store**, you **must** add the +Google Play Billing extension: + +```yaml +dependencies: + purchasely_flutter: 6.0.0-rc.1 + purchasely_google: 6.0.0-rc.1 +``` + +#### Video Player (Required for Video Paywalls) + +If your presentations contain videos, add the Android video player extension: + +```yaml +dependencies: + purchasely_android_player: 6.0.0-rc.1 +``` + +> ⚠️ **All Purchasely packages must be at the exact same version.** Mismatched +> versions will cause runtime errors or unexpected behavior. + +> **Native dependency.** This release targets the Purchasely 6.0 native SDKs, +> pinned to `6.0.0-rc.1` (iOS `Purchasely`, Android `io.purchasely:core`). Both +> pre-releases are published — Android on Maven Central, iOS on the CocoaPods +> trunk — so the project builds from the public repositories. + +### API Key + +You can find your API Key in the Purchasely Console under **App settings > +Backend & SDK configuration**. + +--- + +## SDK Initialization + +> **6.0 — paywall API moved to the builder.** Initialization, presentation +> display and action interception use the fluent builder API below. The previous +> paywall methods (`Purchasely.start(...)`, +> `presentPresentationForPlacement`, `fetchPresentation`, +> `setPaywallActionInterceptorCallback`, `onProcessAction`, +> `setDefaultPresentationResultHandler`, …) have been replaced. See +> [`MIGRATION-v6.md`](./MIGRATION-v6.md) for the complete old→new mapping. All +> other `Purchasely.*` methods (user, products, subscriptions, attributes, +> events) remain source-compatible. + +Initialize the Purchasely SDK as early as possible in your application lifecycle +using `PurchaselyBuilder.apiKey(...)`. Only the API key is required; every other +option has a sensible default. + +### Full Mode (Recommended) + +In `RunningMode.full`, Purchasely handles the entire purchase flow including +transactions and receipts. + +```dart +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +try { + final bool configured = await PurchaselyBuilder.apiKey('') + .runningMode(RunningMode.full) // RunningMode.observer (default) | full + .logLevel(LogLevel.error) // LogLevel.debug in development + .appUserId(null) // set your user id here if you know it + .stores([PLYStore.google]) // Android: google | huawei | amazon + .allowCampaigns(true) // optional campaign display gate + .storekitVersion(StorekitVersion.storeKit2) // iOS: storeKit2 (recommended) | storeKit1 + .start(); + + if (configured) { + print('Purchasely SDK configured successfully'); + } +} catch (e) { + print('Purchasely SDK not configured properly: $e'); +} +``` + +### Observer (PaywallObserver) Mode + +Use `RunningMode.observer` if you have an existing in-app purchase infrastructure +and want to use Purchasely only for presentation display and analytics. **This is +the default in 6.0.** + +```dart +try { + final bool configured = await PurchaselyBuilder.apiKey('') + .runningMode(RunningMode.observer) + .logLevel(LogLevel.error) + .stores([PLYStore.google]) + .start(); +} catch (e) { + print('Purchasely SDK not configured properly'); +} +``` + +> **Default running mode changed.** With the 6.0 native SDK the default +> `RunningMode` is `RunningMode.observer`. Pass `.runningMode(RunningMode.full)` +> to let Purchasely own the purchase flow. + +--- + +## Displaying Presentations + +Purchasely presentations are displayed using **placements**. A placement is a +specific location in your app where you want to display a presentation (e.g. +onboarding, settings, premium feature). + +### Display a Placement + +`PresentationBuilder.placement(id).build()` returns a `PresentationRequest`. +Calling `display([Transition])` shows the presentation and resolves at +**dismiss** with a `PresentationOutcome`. + +```dart +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +try { + final outcome = await PresentationBuilder.placement('ONBOARDING') + .contentId('my_content_id') // optional: associate content with the purchase + .build() + .display(const Transition.fullScreen()); + + // outcome: presentation, purchaseResult, plan, closeReason, error + if (outcome.error != null) { + print('Display error: ${outcome.error!.message}'); + } else if (outcome.purchaseResult == PurchaseResult.purchased || + outcome.purchaseResult == PurchaseResult.restored) { + print('User purchased ${outcome.plan}'); + // Update entitlements to unlock content + } else { + print('User dismissed: ${outcome.closeReason}'); + } +} catch (e) { + print(e); +} +``` + +You can also target a specific screen or product: + +```dart +// A specific presentation by screen id +await PresentationBuilder.screen('SCREEN_ID').build().display(const Transition.modal()); + +// A specific product (content) inside a screen +await PresentationBuilder.screen('SCREEN_ID').contentId('CONTENT_ID').build().display(); +``` + +### Transitions + +`display([Transition])` accepts an optional `Transition`: + +```dart +const Transition.fullScreen(); // full-screen +const Transition.modal(); // modal sheet +const Transition.modal(dismissible: false); +const Transition.push(); // pushed onto the navigation stack +``` + +`TransitionType` also exposes `drawer`, `popin` and `inlinePaywall` for advanced +layouts (with `heightPercentage` and `backgroundColors`). + +### Display Results + +`display([Transition])` resolves with a `PresentationOutcome`: + +| Field | Type | Description | +|-------|------|-------------| +| `presentation` | `Presentation?` | The displayed presentation (or `null` if it never reached display) | +| `purchaseResult` | `PurchaseResult?` | `purchased` \| `restored` \| `cancelled` \| `null` | +| `plan` | `Map?` | The purchased plan (when `purchaseResult` is `purchased` / `restored`) | +| `closeReason` | `CloseReason?` | `button` \| `backSystem` \| `programmatic` (when no purchase) | +| `error` | `PresentationError?` | Display error; mutually exclusive with `closeReason` | + +--- + +## Processing Transactions + +### Full Mode + +In `RunningMode.full`, the Purchasely SDK automatically launches the native +in-app purchase flow when a user taps a purchase button and handles the +transaction. You only need to update entitlements once you have confirmation the +purchase was processed. + +```dart +try { + final outcome = await PresentationBuilder.placement('onboarding') + .build() + .display(); + + if (outcome.purchaseResult == PurchaseResult.purchased || + outcome.purchaseResult == PurchaseResult.restored) { + print('User purchased ${outcome.plan}'); + // Update entitlements to unlock the access to the contents + } +} catch (e) { + print(e); +} +``` + +You can also trigger a purchase programmatically (unchanged): + +```dart +final plan = await Purchasely.purchaseWithPlanVendorId( + vendorId: 'PURCHASELY_PLUS_MONTHLY', +); +``` + +### Observer Mode with Action Interceptor + +In `RunningMode.observer`, you handle purchases with your own infrastructure +while using Purchasely for presentation display. Register an interceptor for the +`purchase` action; the handler returns an `InterceptResult` (there is no more +`onProcessAction`). + +```dart +import 'package:flutter/foundation.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +await Purchasely.interceptAction( + PresentationActionKind.purchase, + (info, payload) async { + if (payload is! PurchasePayload) { + return InterceptResult.notHandled; + } + try { + // The store product id (sku) the user tapped on in the presentation + final storeProductId = payload.plan.productId; + + if (defaultTargetPlatform == TargetPlatform.android) { + // Only for Android you can retrieve the subscription offer details + final basePlanId = payload.subscriptionOffer?.basePlanId; + final offerId = payload.subscriptionOffer?.offerId; + final offerToken = payload.subscriptionOffer?.offerToken; + } + + final success = await MyPurchaseSystem.purchase(storeProductId); + if (success) { + Purchasely.synchronize(); // Synchronize all purchases with Purchasely + return InterceptResult.success; + } + return InterceptResult.failed; + } catch (e) { + print(e); + return InterceptResult.failed; + } + }, +); + +await Purchasely.interceptAction( + PresentationActionKind.restore, + (info, payload) async { + try { + await MyPurchaseSystem.restorePurchases(); + Purchasely.synchronize(); + return InterceptResult.success; + } catch (e) { + return InterceptResult.failed; + } + }, +); +``` + +--- + +## Action Interceptor + +The action interceptor lets you intercept and handle user actions on the +presentation. Register **one handler per action kind** with +`Purchasely.interceptAction(kind, handler)`. The +handler returns an `InterceptResult` that tells the SDK how the action was +handled: + +- `InterceptResult.success` — you handled the action successfully +- `InterceptResult.failed` — you tried to handle it but it failed +- `InterceptResult.notHandled` — let the SDK perform its default behaviour + +### Available Action Kinds + +| Kind (`PresentationActionKind`) | Payload | Description | +|---------------------------------|---------|-------------| +| `purchase` | `PurchasePayload` | User tapped a purchase button | +| `restore` | — | User tapped the restore button | +| `login` | — | User tapped the login button | +| `close` / `closeAll` | `ClosePayload` / `CloseAllPayload` | User tapped the close button | +| `navigate` | `NavigatePayload` | User wants to navigate to an external URL | +| `openPresentation` | `OpenPresentationPayload` | User wants to open another presentation | +| `openPlacement` | `OpenPlacementPayload` | User wants to open another placement | +| `promoCode` | — | User wants to enter a promo code | +| `webCheckout` | `WebCheckoutPayload` | User wants to start a web checkout | + +### Implementation + +```dart +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +await Purchasely.interceptAction( + PresentationActionKind.navigate, + (info, payload) async { + if (payload is NavigatePayload) { + print('User wants to navigate to ${payload.url}'); + // open payload.url with your router / url_launcher + return InterceptResult.success; + } + return InterceptResult.notHandled; + }, +); + +await Purchasely.interceptAction( + PresentationActionKind.login, + (info, payload) async { + print('User wants to login'); + // Present your own screen for the user to log in + Purchasely.userLogin('MY_USER_ID'); + return InterceptResult.success; + }, +); +``` + +### Removing interceptors + +```dart +await Purchasely.removeInterceptor(PresentationActionKind.navigate); +await Purchasely.removeAllInterceptors(); +``` + +--- + +## User Identification + +### Anonymous Users + +The Purchasely SDK automatically generates and assigns an `anonymous_user_id` to +each user, maintaining consistency as long as the app remains installed on the +device. + +```dart +final anonymousId = await Purchasely.anonymousUserId; +print('Anonymous User ID: $anonymousId'); +``` + +### User Login + +To authenticate users and associate purchases with their account: + +```dart +final refresh = await Purchasely.userLogin('123456789'); +if (refresh) { + // You should call your backend to refresh user entitlements + print('User logged in, refresh entitlements'); +} +``` + +### User Logout + +```dart +Purchasely.userLogout(); +``` + +### Login from Presentation + +To handle the login button on the presentation, intercept the `login` action: + +```dart +await Purchasely.interceptAction( + PresentationActionKind.login, + (info, payload) async { + // Present your own screen for the user to log in + Purchasely.userLogin('MY_USER_ID'); + return InterceptResult.success; + }, +); +``` + +--- + +## Subscription Status & Entitlements + +### Retrieve User Subscriptions + +Purchasely offers a way to retrieve active subscriptions directly from your +mobile app: + +```dart +try { + final subscriptions = await Purchasely.userSubscriptions(); + if (subscriptions.isNotEmpty) { + print(subscriptions.first.plan); + print(subscriptions.first.subscriptionSource); + print(subscriptions.first.nextRenewalDate); + print(subscriptions.first.cancelledDate); + } +} catch (e) { + print(e); +} +``` + +Expired subscriptions are available via `Purchasely.userSubscriptionsHistory()`. + +> **Note**: There is a **few seconds delay** for `Purchasely.userSubscriptions()` +> to be updated after a purchase or restoration. If you rely on this method right +> after a purchase, **wait for 3 seconds** before calling it. + +--- + +## Custom User Attributes + +Custom User Attributes allow you to segment users and personalize their journey. + +### Supported Types + +`String`, `int`, `double`, `bool`, `DateTime`, and arrays of those types. + +### Setting Attributes + +```dart +Purchasely.setUserAttributeWithString('gender', 'man'); +Purchasely.setUserAttributeWithInt('age', 21); +Purchasely.setUserAttributeWithDouble('weight', 78.2); +Purchasely.setUserAttributeWithBoolean('premium', true); +Purchasely.setUserAttributeWithDate('subscription_date', DateTime.now()); +Purchasely.setUserAttributeWithStringArray('tags', ['sport', 'news']); +``` + +### Retrieving Attributes + +```dart +final attributes = await Purchasely.userAttributes(); +print(attributes); // Map of key -> value + +final dateAttribute = await Purchasely.userAttribute('subscription_date'); +// DateTime values are parsed automatically when possible +``` + +### Incrementing / Decrementing Counters + +```dart +Purchasely.incrementUserAttribute('viewed_articles'); +Purchasely.incrementUserAttribute('viewed_articles', value: 3); +Purchasely.decrementUserAttribute('viewed_articles'); +Purchasely.decrementUserAttribute('viewed_articles', value: 7); +``` + +### Clearing Attributes + +```dart +Purchasely.clearUserAttribute('size'); +Purchasely.clearUserAttributes(); +``` + +> **Note**: `Purchasely.userLogout()` clears all custom user attributes. + +--- + +## Event Listeners + +### UI / SDK Events Listener + +When users interact with Purchasely Screens, the SDK triggers events. Implement +an event listener to forward these events to analytics platforms. + +```dart +Purchasely.listenToEvents((event) { + print('Event received: ${event.name}'); + print('Event properties: ${event.properties.event_name}'); + // Forward to your analytics platform +}); + +// Stop listening when no longer needed: +Purchasely.stopListeningToEvents(); +``` + +### Custom User Attributes Listener + +When a user submits answers to a survey, custom user attributes can be set +automatically by the SDK: + +```dart +class MyUserAttributeListener implements UserAttributeListener { + @override + void onUserAttributeSet(String key, PLYUserAttributeType type, dynamic value, + PLYUserAttributeSource source) { + if (source == PLYUserAttributeSource.purchasely) { + // Process attribute set by Purchasely (e.g., from surveys) + } + } + + @override + void onUserAttributeRemoved(String key, PLYUserAttributeSource source) {} +} + +Purchasely.setUserAttributeListener(MyUserAttributeListener()); +``` + +--- + +## Pre-fetching Screens + +Pre-fetch presentations from the network before displaying them for a better +user experience. + +### Benefits + +- Display the Screen only after it has been loaded +- Handle network errors gracefully +- Show a custom loading screen +- Pre-load during app navigation + +### Implementation + +Build a `PresentationRequest`, `preload()` it to fetch the screen from the +network, then `display()` the **same** request when you are ready. + +```dart +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +try { + final request = PresentationBuilder.placement('ONBOARDING').build(); + + // Preload resolves once the screen is loaded + final presentation = await request.preload(); + + if (presentation.type == PresentationType.deactivated) { + // No paywall to display for this placement + return; + } + if (presentation.type == PresentationType.client) { + // Display your own paywall (BYOS) — plan summaries are in presentation.plans + return; + } + + // Display the preloaded presentation; resolves at dismiss + final outcome = await request.display(const Transition.fullScreen()); + + if (outcome.purchaseResult == PurchaseResult.purchased || + outcome.purchaseResult == PurchaseResult.restored) { + print('User purchased ${outcome.plan}'); + } else { + print('Dismissed: ${outcome.closeReason}'); + } +} catch (e) { + print(e); +} +``` + +### Presentation Types + +| Type (`PresentationType`) | Description | +|---------------------------|-------------| +| `normal` | Default Purchasely paywall | +| `fallback` | Fallback paywall (requested one not found) | +| `deactivated` | No paywall for this placement | +| `client` | Your own paywall (BYOS) | + +--- + +## Inline Presentations + +To render a presentation inline (embedded) inside your widget tree — as opposed +to full-screen / modal — use the `PLYPresentationView` widget with a +`PresentationRequest`. The widget preloads the request and hands the resulting +presentation to the native inline view. + +```dart +import 'package:purchasely_flutter/native_view_widget.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +final request = PresentationBuilder.placement('onboarding') + .onDismissed((outcome) => print('inline dismissed: ${outcome.purchaseResult}')) + .build(); + +// In your build(): +Expanded( + child: PLYPresentationView( + request: request, + loadingBuilder: const Center(child: CircularProgressIndicator()), + errorBuilder: (context, error) => Text('Error: ${error.message}'), + ), +); +``` + +--- + +## Deeplinks Management + +To enable Purchasely to display screens via deeplinks, you need to: + +1. Allow the SDK to open deeplinks +2. Set a default presentation handler to receive the result +3. (Optional) check whether a deeplink is handled by Purchasely + +### Allowing the Display + +Deeplink display is allowed via the start builder: + +```dart +await PurchaselyBuilder.apiKey('') + .allowDeeplink(true) + .start(); +``` + +`Purchasely.allowDeeplink(bool)` can also toggle this at runtime. The old +`readyToOpenDeeplink` name remains only as a deprecated alias. + +### Setting the Default Presentation Handler + +Retrieve the result of user actions on presentations opened via deeplinks by +attaching `onDismissed` to a default-source request: + +```dart +PresentationBuilder.defaultSource() + .onDismissed((outcome) { + print('Presentation dismissed: ${outcome.purchaseResult}'); + if (outcome.plan != null) { + print('Plan: ${outcome.plan}'); + } + }) + .build() + .display(); +``` + +Alternatively, register a single app-wide handler with +`Purchasely.setDefaultPresentationDismissHandler((outcome) { … })`. + +> **Where the outcome is delivered.** A dismissed presentation produces one +> `PLYPresentationOutcome`, delivered to the **per-presentation `onDismissed` if +> one is set, otherwise to the global default handler**. The deciding factor is +> the presence of `onDismissed` — awaiting `display()` returns the outcome too +> but does not by itself silence the global handler. Pick one channel per +> presentation: await it, set `onDismissed`, or leave both off and let the global +> handler catch it. The global handler is also what receives presentations the +> SDK opens itself (deeplinks, campaigns, promoted in-app purchases). + +### Checking a Deeplink + +```dart +final handled = await Purchasely.handleDeeplink('app://ply/presentations/'); +print('Deeplink handled by Purchasely? $handled'); +``` + +--- + +## Platform-Specific Features + +### StoreKit Selection (iOS) + +Choose between StoreKit 1 and StoreKit 2 for iOS: + +```dart +await PurchaselyBuilder.apiKey('') + .storekitVersion(StorekitVersion.storeKit2) // or StorekitVersion.storeKit1 + .start(); +``` + +> **Recommendation**: Use StoreKit 2 (`StorekitVersion.storeKit2`) for new +> integrations. + +### Android Stores + +Purchasely supports multiple Android stores: + +```dart +await PurchaselyBuilder.apiKey('') + .stores([PLYStore.google]) // PLYStore.google | PLYStore.huawei | PLYStore.amazon + .start(); +``` + +To use multiple stores: + +```dart +.stores([PLYStore.google, PLYStore.huawei]) +``` + +> **Note**: Install the corresponding dependencies for each store you want to +> support. + +### Android-Specific Purchase Parameters + +When intercepting purchases on Android, you can access additional parameters from +the typed `PurchasePayload`: + +```dart +await Purchasely.interceptAction( + PresentationActionKind.purchase, + (info, payload) async { + if (payload is PurchasePayload && + defaultTargetPlatform == TargetPlatform.android) { + final basePlanId = payload.subscriptionOffer?.basePlanId; + final offerId = payload.subscriptionOffer?.offerId; + final offerToken = payload.subscriptionOffer?.offerToken; + } + return InterceptResult.notHandled; + }, +); +``` + +### Native Subscriptions Screen + +> **Removed (BREAKING): `presentSubscriptions()`.** The native subscriptions +> screen was removed from the 6.0 SDKs on both platforms, so +> `Purchasely.presentSubscriptions()` has been **removed** from the SDK — the +> method no longer exists. Build your own UI with `userSubscriptions()` / +> `userSubscriptionsHistory()`. The cancellation survey UI was also removed, so +> `Purchasely.displaySubscriptionCancellationInstruction()` is a no-op on both +> platforms. + +### iOS Presentation Fields + +The native iOS 6.0 SDK does not currently expose `closeReason` on +`PLYPresentationOutcome`, nor a loaded presentation `contentId` on +`PLYPresentation`. Flutter therefore reports `outcome.closeReason` and +`presentation.contentId` as `null` on iOS rather than synthesising values. Android +6.0 does expose both fields. + +### Plan Offer Fields + +Android 6.0 renamed introductory-price helpers to offer-price helpers. Flutter +exposes the v6 names on `PLYPlan` (`hasOfferPrice`, `offerPrice`, `offerAmount`, +`offerDuration`, `offerPeriod`) and keeps the old `intro*` fields populated as +deprecated compatibility aliases. + +--- + +## Troubleshooting + +1. **SDK not configured**: Ensure you call + `PurchaselyBuilder.apiKey('…')...start()` before any other SDK method. + +2. **Purchases not working**: Verify that you've added the correct store + dependencies and they're all at the same version. + +3. **Presentation not displaying**: Check that: + - The placement exists in your Purchasely Console + - The SDK is properly initialized + - You have an active internet connection + +4. **StoreKit issues on iOS**: Ensure your iOS deployment target is at least 13.4. + +### Debug Mode + +Enable debug logging during development: + +```dart +await PurchaselyBuilder.apiKey('') + .logLevel(LogLevel.debug) // Use LogLevel.error in production + .start(); +``` + +--- + +## Additional Resources + +- [Purchasely Console](https://console.purchasely.io) +- [pub.dev Package](https://pub.dev/packages/purchasely_flutter) +- [Purchasely Documentation](https://docs.purchasely.com)