Overview
SDK 2.0 replaced the 1.x choice between “async result” and “global handlers” with three pieces that each have one job. There is no pattern to pick: the question is only where each piece lives in your app.
The rule of thumb: control flow at the call site, observation on the stream, purchases in the controller. Nothing in 2.0 requires a global handler to run your fallback.
1. The purchase controller
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. What you implement is a plain Dart interface, registered once.configure and nowhere else, because both native SDKs bind the controller once there and expose no setter. It survives reset().
The bridge refuses to guess: an answer it does not recognize throws rather than defaulting to purchased, since a phantom success grants access nobody paid for.
Registering none is supported: the SDK never attempts a purchase at all, and every presentation records EncorePublisherOutcome.notAttempted.
2. Control flow at the call site
show() never throws, so there is no try/catch to write and no case where your fallback silently fails to run.
When the widget can’t see your continuation logic
In 1.x this was the reason to reach for global handlers. In 2.0, keep the call site local and hand the decision outward:3. Observation on the stream
setOn* setters, which replaced the previous handler, this is a broadcast stream: several listeners can subscribe at once, matching the AsyncStream (iOS) and SharedFlow (Android) it bridges.
There is no replay. The native streams buffer nothing for late subscribers, by design, since outcomes can carry user-scoped claim data. Subscribe at startup if you want all of them.
The stream is also the only way to see EncoreStrictUnlockVerified, a strict-mode claim the server confirmed after its flow ended, possibly on a later launch. No show() return value can carry that, which is the stream’s reason for existing. It requires EncoreUnlockMode.strict on configure; under the default optimistic it never fires.
Reading the record
switch, and the compiler tells you when you’ve forgotten a branch. Fact readers (result.claim, result.advertiser, result.publisher, result.dismissal) let a call site that cares about one axis skip the pattern match; they return null when nothing was presented, which is a different fact from notAttempted.
EncoreNotPresented is not a synonym for “something went wrong”. It also covers noOffers, experimentControl, and useCaseUnavailable, all healthy states that still need your original flow to continue. Run your fallback off the whole no-claim, no-purchase reading, not off the error reason.unknown case. An unrecognized advertiser outcome reads as EncoreAdvertiserNotAttempted.
Platform-specific notes
- Native renders the UI. SwiftUI on iOS, Compose on Android. The plugin is a thin bridge over platform channels.
- Some axis values are platform-specific.
noForegroundActivityandiapFirstDeclinedare Android-only;unsupportedOS,userCancelled,lastOfferDeclined, anddismissedare iOS-only. Code written against the full enum is portable. - Android’s
Activityis absorbed by the plugin, not surfaced to Dart. It is valid only for the duration of the call and must never be retained, so one Dart contract covers both platforms. Encore.placement(id)is static. BothEncore.placement('id')andEncore.shared.placement('id')work; the instance form is deprecated.
See also
- Quickstart: Present Offers
- Updating to 2.x, if you are migrating an existing 1.x integration
- Platform detail: iOS SDK reference and Android SDK reference