> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encorekit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Updating to 2.x

> Step-by-step migration guide for upgrading the encore_flutter plugin from 1.x to 2.x.

## Overview

`encore_flutter` 2.0 moves the plugin onto the native Encore 2.0 surface (EncoreKit 2.0.0 on iOS, `com.encorekit:encore` 2.0.1 on Android). Both natives removed the global purchase and passthrough handlers and replaced the presentation result with a factual two-funnel record, so this is a breaking release:

* **Purchases run through an `EncorePurchaseController`** you implement and register at `configure`. The SDK never runs purchase code you didn't write.
* **`show()` never throws.** Every failure, including "nothing was presented", is a value on the result.
* **The result is a factual record**, not a verdict: two independent funnels plus how the sheet ended.
* **`Encore.shared.outcomes`** is the passive observation channel, and the only way to see cross-launch verifications.

<Warning>
  **There is no compatibility shim for `onPurchaseRequest`.** The new answer is three-valued and 1.x's `onPurchaseRequestResult(bool)` has no way to say "deferred". A shim would have to guess, and every guess reports an Ask to Buy or SCA purchase as either a decline or a completed sale. Silently misreporting revenue is worse than a compile error, so the 1.x API is gone rather than quietly wrong.
</Warning>

***

## Step 1: Update the package

```yaml theme={null}
dependencies:
  encore_flutter: ^2.0.0
```

```bash theme={null}
flutter pub get
cd ios && pod install --repo-update && cd ..
```

2.0 removes the 1.x surface outright rather than deprecating it, so every call site that needs attention fails to analyze. Run `flutter analyze`: the errors are your migration worklist, and each one maps to a step below. The [removed API table](#removed-apis-at-a-glance) maps every 1.x symbol to its replacement.

***

## Step 2: Implement a purchase controller

This is the largest change. Dart cannot implement a Swift protocol or a Kotlin interface, so each native plugin owns the conformance and forwards over the method channel, suspending until Dart answers. The Dart-side API is an interface you implement and register at `configure`, mirroring where both natives bind it.

**Before**

```dart theme={null}
Encore.shared.onPurchaseRequest((request) async {
  final products = await Purchases.getProducts([request.productId]);
  if (products.isNotEmpty) {
    await Purchases.purchaseStoreProduct(products.first);
  }
});
```

**After**

```dart theme={null}
class AppPurchases implements EncorePurchaseController {
  @override
  Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request) async {
    final products = await Purchases.getProducts([request.productId]);
    if (products.isEmpty) throw StateError('unknown product ${request.productId}');
    try {
      await Purchases.purchaseStoreProduct(products.first);
      return EncorePurchaseResult.purchased;
    } on PlatformException catch (e) {
      if (PurchasesErrorHelper.getErrorCode(e) ==
          PurchasesErrorCode.purchaseCancelledError) {
        return EncorePurchaseResult.cancelled;
      }
      rethrow; // a real failure — Encore records it as EncorePublisherOutcome.failed
    }
  }
}

await Encore.shared.configure(
  apiKey: 'pk_...',
  purchaseController: AppPurchases(),
);
```

Two things changed beyond the shape:

1. **The controller reports an outcome.** 1.x's `onPurchaseRequest` could not report success at all, and `onPurchaseRequestResult` could only report a `bool`. The controller returns an `EncorePurchaseResult`, and an unrecognized answer throws rather than defaulting to `purchased`, because a phantom success grants access nobody paid for.
2. **Throwing is meaningful.** A throw is recorded as `EncorePublisherOutcome.failed`, and the flow continues. Map your billing layer's "user cancelled" throw to `EncorePurchaseResult.cancelled` rather than letting it propagate.

### `pending` is not a failure

```dart theme={null}
enum EncorePurchaseResult { purchased, cancelled, pending }
```

`pending` means the store **deferred** the purchase: Ask to Buy (parental approval) on iOS, or SCA or a pending Play transaction on Android. The user has neither bought nor abandoned. The purchase may settle minutes or days later, and the store's eventual webhook is the source of truth.

1.x's boolean `onPurchaseRequestResult` could not express this state, so it reported every deferred purchase as a failure. When you map your billing layer onto `EncorePurchaseResult`, returning `cancelled` for a deferred purchase silently misreports revenue: a sale still in flight is recorded as a decline, on the presentation record and in every analytics surface downstream. Route your billing library's deferred branch to `EncorePurchaseResult.pending`.

### Registration

The controller can only be registered at `configure`, because both native SDKs bind it once there and expose no setter afterwards. `hasPurchaseController` is sent explicitly, so native builds its bridge only when Dart registered one.

Registering none is a supported configuration, not a broken one: the SDK never attempts a purchase at all, which is a different and more accurate behavior than registering a controller that always fails. Every presentation records `EncorePublisherOutcome.notAttempted`.

<Note>
  The controller survives [`reset()`](../quickstart/user-management). It is app-level infrastructure, not user state, so logging a user out does not require re-registering it.
</Note>

<Note>
  Android's native controller also hands over the foreground `Activity`. The plugin **absorbs** it, since it is valid only for the duration of the call and must never be retained, and iOS has no equivalent. One Dart contract therefore covers both platforms.
</Note>

***

## Step 3: Update `show()` call sites and results

`show()` never throws. `EncorePresentationResult` is now a sealed pair, and the 1.x `Granted` / `Claimed` / `NotGranted` cases are gone.

**Before**

```dart theme={null}
switch (result) {
  case EncorePresentationResultGranted(:final offerId):
    grantAccess();
  case EncorePresentationResultNotGranted(:final reason):
    proceedWithCancellation(reason);
}
```

**After**

```dart theme={null}
final result = await Encore.placement('cancel_flow').show();

switch (result) {
  case EncoreNotPresented(:final reason):
    proceedWithCancellation(reason);
  case EncorePresented(:final outcome):
    final converted = outcome.advertiser is EncoreAdvertiserClaimed ||
        outcome.advertiser is EncoreAdvertiserVerified ||
        outcome.publisher == EncorePublisherOutcome.purchased;
    converted ? grantAccess() : proceedWithCancellation(outcome.dismissal);
}
```

`result.claim` reads the claimed offer directly when you don't want to pattern-match the whole record:

```dart theme={null}
final converted = result.claim != null ||
    result.publisher == EncorePublisherOutcome.purchased;
```

The record carries **raw facts only**. There is deliberately no SDK-computed "unlocked" verdict, because what a claim means is a property of the variant flow that served it, and a projection over app-global config can contradict the flow that actually ran.

| 1.x case                                     | Closest 2.0 reading                                                                                                                               |
| :------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
| `EncorePresentationResultGranted`            | `EncorePresented` with `result.claim != null`                                                                                                     |
| `EncorePresentationResultClaimed`            | `EncorePresented` with `result.claim != null`                                                                                                     |
| `EncorePresentationResultNotGranted(reason)` | Either `EncoreNotPresented(reason)` (nothing was shown) or `EncorePresented` with no claim and no purchase; `outcome.dismissal` says how it ended |
| A thrown presentation error                  | `EncoreNotPresented(reason: EncoreNotPresentedReason.error)` with `errorType` / `errorMessage`                                                    |

<Note>
  `EncoreNotPresented` is not a synonym for failure. It also covers `noOffers`, `experimentControl`, and `useCaseUnavailable`, all healthy states that still need your original flow to continue.
</Note>

Every decoder maps an unrecognized value to an explicit `unknown` rather than to a neighbouring case, so a newer native can never be silently misread as `notAttempted`.

***

## Step 4: Replace `onPassthrough` and `onPurchaseComplete`

Both are removed, and each was verified absent from both native SDKs before deletion.

* **`onPurchaseComplete`**, along with `EncoreBillingPurchaseResult`, is gone: the SDK no longer runs purchases itself, so there is no native purchase to report, and your controller already sees every purchase it runs.
* **`onPassthrough`** becomes a reading of the record. "Encore did not intercept" is now:

```dart theme={null}
final passedThrough = result is EncoreNotPresented ||
    (result.claim == null && result.publisher != EncorePublisherOutcome.purchased);
```

For cross-cutting observation, subscribe to the outcomes stream instead:

```dart theme={null}
Encore.shared.outcomes.listen((outcome) {
  switch (outcome) {
    case EncorePlacementPresentation(:final placementId, :final result):
      analytics.log('encore_presentation', placementId, result);
    case EncoreStrictUnlockVerified(:final transactionId):
      entitlements.refresh(transactionId);
  }
});
```

The stream is multicast and has **no replay**: subscribe at startup, or you miss outcomes emitted before you subscribed. It earns its place through `EncoreStrictUnlockVerified`, which resolves after its flow ended, possibly on a later launch, and so can never be a `show()` return value. `EncoreUnlockMode.strict` on `configure` is what makes that event reachable.

***

## Removed APIs at a glance

| 1.x surface                                                                   | 2.0 replacement                                                                    |
| :---------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- |
| `Encore.shared.onPurchaseRequest(handler)`                                    | `EncorePurchaseController` passed to `configure(purchaseController:)`              |
| `Encore.shared.onPurchaseRequestResult(bool)`                                 | The controller's `EncorePurchaseResult` return value, which can also say `pending` |
| `Encore.shared.onPurchaseComplete(handler)` and `EncoreBillingPurchaseResult` | Removed; your controller already knows                                             |
| `Encore.shared.onPassthrough(handler)`                                        | Read the record at the call site, or subscribe to `Encore.shared.outcomes`         |
| `Encore.shared.placements.setClaimEnabled(bool)` and `EncorePlacements`       | `Encore.shared.setClaimEnabled(bool)`                                              |
| `EncorePresentationResultGranted` / `Claimed` / `NotGranted`                  | `EncorePresented(outcome)` / `EncoreNotPresented(reason)`                          |
| A thrown `show()`                                                             | `EncoreNotPresented(reason: EncoreNotPresentedReason.error)`                       |
| *(no 1.x equivalent)*                                                         | `Encore.shared.outcomes`, the outcomes stream                                      |
| *(no 1.x equivalent)*                                                         | `EncoreUnlockMode` on `configure`                                                  |
| *(no 1.x equivalent)*                                                         | `EncorePurchaseRequest.basePlanId`                                                 |

***

## Behavior changes to be aware of

These analyze fine but behave differently at runtime:

* **Errors no longer throw.** Old `try`/`catch` fallback blocks around `show()` are dead code; run fallback off `EncoreNotPresented` instead.
* **Claim-then-purchase records both funnels.** 1.x collapsed the flow to a single winner. 2.0 carries the advertiser claim and `publisher: purchased` side by side.
* **A deferred purchase is now visible**, provided your controller returns `EncorePurchaseResult.pending` for it.
* **`EncoreUseCase` now actually reaches the native SDK.** In 1.x the value was dropped at the bridge, because neither native plugin implemented the channel method carrying it, so `EncoreUseCase.rewardUsers` could never present and `headline` / `subheadline` overrides were silently ignored. Both bridges now forward all three on every `show()`, which also retires the `use_case_unsupported` degradation path.

***

## New in 2.0

```dart theme={null}
await Encore.shared.configure(
  apiKey: 'pk_...',
  purchaseController: AppPurchases(),
  unlock: EncoreUnlockMode.strict,
);

await Encore.placement('milestone_reached')
    .useCase(EncoreUseCase.rewardUsers)
    .headline("That's three days in a row staying informed 🔥")
    .subheadline("Here's a little thank you from us")
    .show();
```

* **The outcomes stream**, including late `EncoreStrictUnlockVerified` events that survive process death.
* **`EncoreUnlockMode`.** `strict` verifies claims server-side and persists unverified claims across launches; `optimistic` (the default, and 1.x's behavior) records the claim and finishes.
* **A working reward surface**, claim-only, which never falls back to the churn-intervention sheet.
* **`EncoreClaimedOffer`** carrying `campaignId`, `advertiserName`, and the `transactionId` that joins a claim to its later verification.

***

## Verify the migration

<Check>
  Confirm each of these before shipping:
</Check>

1. `flutter analyze` is clean, with no references to removed 1.x APIs.
2. If your Encore flows include an in-app purchase, an `EncorePurchaseController` is passed to `configure`.
3. Your controller returns `pending` for deferred purchases, and `cancelled` only for a genuine user cancellation.
4. Every fallback path runs off the no-claim, no-purchase reading, not a `catch` block.
5. Test the full flow on a device on both platforms: present a placement, claim an offer, complete a purchase, and dismiss without acting. Each path should resolve exactly once with the result you expect.

***

If you hit anything this guide doesn't cover, reach out to [admin@encorekit.com](mailto:admin@encorekit.com).
