encore_flutter 2.0.1
encore_flutter: ^2.0.1 copied to clipboard
Flutter plugin wrapping the native Encore iOS and Android SDKs for monetization, offers, and entitlements. All offer UI is rendered natively via StoreKit (iOS) and Play Billing (Android).
Encore Flutter SDK #
Flutter plugin wrapping the native Encore iOS and Android SDKs. All offer UI is rendered natively — this plugin bridges configuration, identity, placement presentation, purchases, and outcome reporting via platform channels.
Installation #
Add to your pubspec.yaml:
dependencies:
encore_flutter: ^2.0.0
iOS #
The EncoreKit CocoaPod is automatically included as a transitive dependency of the Flutter plugin — no manual pod configuration is needed.
Minimum deployment target: iOS 15.0.
Android #
The com.encorekit:encore AAR is included as a transitive dependency of the Flutter plugin — no manual Gradle configuration is needed.
Minimum SDK: 26.
Usage #
Configure #
Call once early in your app lifecycle (e.g. in main()):
import 'package:encore_flutter/encore_flutter.dart';
await Encore.shared.configure(
apiKey: 'your_api_key',
purchaseController: AppPurchases(), // see below
logLevel: EncoreLogLevel.debug,
);
Run purchases #
Encore controls when a purchase happens; your app owns how. Implement
EncorePurchaseController and register the instance at configure — this is
the only purchase path, and the SDK never runs purchase code you did not write.
With no controller registered nothing is ever charged, and a presentation whose
layout offers a product resolves EncorePublisherOutcome.notAttempted.
class AppPurchases implements EncorePurchaseController {
@override
Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request) async {
// request.productId / .placementId / .promoOfferId (iOS) / .basePlanId (Android)
try {
await mySubscriptionManager.purchase(request.productId);
return EncorePurchaseResult.purchased;
} on UserCancelled {
return EncorePurchaseResult.cancelled;
} on PurchaseDeferred {
return EncorePurchaseResult.pending; // Ask to Buy / SCA
}
// Throw for a real failure — Encore records it and resolves the
// presentation with EncorePublisherOutcome.failed.
}
}
The result is three-valued on purpose. pending is a deferred purchase (Ask to
Buy, SCA, a pending Play transaction): the user has not abandoned the flow and
has not been charged yet, so neither purchased nor cancelled is true, and
the store's eventual webhook is the source of truth.
Registration can only happen at configure, because both native SDKs bind the
controller once and expose no setter. It survives reset() — it is app-level
infrastructure, not user state.
Identify User #
After authentication:
await Encore.shared.identify(
userId: 'user_123',
attributes: EncoreUserAttributes(
email: 'user@example.com',
subscriptionTier: 'premium',
),
);
Update Attributes #
await Encore.shared.setUserAttributes(
EncoreUserAttributes(billingCycle: 'annual'),
);
Present Offers #
final result = await Encore.placement('cancel_flow').show();
switch (result) {
case EncoreNotPresented(:final reason):
print('Nothing shown: ${reason.name}');
case EncorePresented(:final outcome):
print('advertiser=${outcome.advertiser} '
'publisher=${outcome.publisher.name} '
'dismissal=${outcome.dismissal.name}');
}
show() never throws. Either nothing appeared — EncoreNotPresented, with a
reason — or the interaction ran and EncorePresented carries the complete
factual record.
That record has two independent funnels plus how the sheet ended:
| Axis | Type | What it records |
|---|---|---|
advertiser |
EncoreAdvertiserOutcome |
How far the Encore offer claim got: notAttempted / claimed / verified / cooldown / failed |
publisher |
EncorePublisherOutcome |
What your purchase controller reported: notAttempted / purchased / cancelled / pending / failed |
dismissal |
EncoreDismissReason |
How the sheet went away |
The two funnels are independent — a single presentation can claim an offer and
run a purchase. notAttempted is a real value ("funnel open, nothing entered
it"), which is a different fact from null ("never presented at all").
There is deliberately no SDK-computed "unlocked" verdict: what a claim means is a property of the variant flow that served it, so any projection over app-global config could contradict the flow that actually ran. Branch on the raw axes:
final converted = result.claim != null ||
result.publisher == EncorePublisherOutcome.purchased;
result.claim reads through both claimed and verified and gives you the
EncoreClaimedOffer — campaignId, advertiserName, and a nullable
transactionId that joins this claim to the completion that lands days later
(the same id the offer-completed webhook and the server-side
sdk_offer_completed event carry). It is nullable because the transaction write
can fail while the claim still happened; the claim is reported either way.
Observe outcomes #
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);
}
});
A broadcast Stream carrying every show() resolution — including the ones
that presented nothing — plus strict-unlock verifications.
This is the observation channel; control flow belongs at the call site. Its
reason for existing is EncoreStrictUnlockVerified, which resolves after the
flow that produced it ended, possibly on a later launch, and so can never be a
show() return value. It only fires under EncoreUnlockMode.strict. There is
no replay — subscribe at startup if you want every outcome.
Use cases and copy #
To present the reward surface instead of the monetization sheet — a brand-funded reward at a moment the user has just accomplished something — set the use case and supply your own copy:
final result = await Encore.placement('streak_complete')
.useCase(EncoreUseCase.rewardUsers)
.headline('7 day streak!')
.subheadline("Here's a little thank you from us")
.show();
useCase defaults to EncoreUseCase.reduceChurn, so existing calls are
unchanged. EncoreUseCase.rewardUsers is claim-only and never presents an
in-app purchase.
Copy resolves in strict priority: the value you pass here, then the value configured in the Encore portal, then the shipped template default. A blank string is ignored, so the chain falls through rather than rendering an empty line.
headline / subheadline apply to both use cases — the native side writes
them into the variable the active template reads.
If a use case resolves no layout of its own, the result is
EncoreNotPresented(reason: EncoreNotPresentedReason.useCaseUnavailable) —
typically because the use case is not enabled for this app. It is a correct
no-op, not an error, and it never falls back to the monetization sheet:
putting an IAP screen in front of a user at a moment they were meant to be
rewarded is worse than showing nothing.
Claim button #
await Encore.shared.setClaimEnabled(false); // gray out and disable
Reset (Logout) #
await Encore.shared.reset();
Migrating from 1.x #
2.0 tracks the native Encore 2.0 SDKs, which removed onPurchaseRequest
entirely and replaced it with a registered purchase controller. There is no
compatibility shim, because the only shim available would have had to collapse
the three-valued purchase result back into 1.x's void/bool — which is
exactly the bug 2.0 fixes.
| 1.x | 2.0 |
|---|---|
Encore.shared.onPurchaseRequest(handler) |
Implement EncorePurchaseController, pass it to configure(purchaseController:) |
Encore.shared.onPurchaseRequestResult(handler) |
Same — and return pending instead of false for deferred purchases |
Encore.shared.onPurchaseComplete(handler) |
Removed. The SDK no longer runs purchases itself, so there is no native purchase to report — your controller already sees every purchase it runs |
Encore.shared.onPassthrough(handler) |
Removed. Read the result instead: EncoreNotPresented means nothing was shown, and a presented record carries the dismissal and both funnels |
Encore.shared.placements.setClaimEnabled(x) |
Encore.shared.setClaimEnabled(x) |
EncorePresentationResultGranted |
EncorePresented + result.claim != null || result.publisher == EncorePublisherOutcome.purchased |
EncorePresentationResultClaimed |
EncorePresented + result.claim != null |
EncorePresentationResultNotGranted(reason: String) |
EncoreNotPresented(reason: EncoreNotPresentedReason) for "nothing shown", or EncorePresented + outcome.dismissal for "shown, then dismissed" — 1.x conflated the two |
EncoreBillingPurchaseResult |
Removed with onPurchaseComplete |
'use_case_unsupported' |
Gone. 1.x dropped the use case at the bridge, so rewardUsers could never present; 2.0 forwards it, leaving EncoreNotPresentedReason.useCaseUnavailable as the only unavailability reason |
Before:
await Encore.shared.configure(apiKey: 'key', logLevel: EncoreLogLevel.debug);
Encore.shared.onPurchaseRequestResult((request) async {
return await mySubscriptionManager.purchase(request.productId);
});
Encore.shared.onPassthrough((placementId) => runOriginalAction());
final result = await Encore.placement('cancel_flow').show();
if (result is EncorePresentationResultGranted) grantAccess();
After:
class AppPurchases implements EncorePurchaseController {
@override
Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request) async {
return await mySubscriptionManager.purchase(request.productId)
? EncorePurchaseResult.purchased
: EncorePurchaseResult.cancelled; // or .pending, when deferred
}
}
await Encore.shared.configure(
apiKey: 'key',
purchaseController: AppPurchases(),
logLevel: EncoreLogLevel.debug,
);
final result = await Encore.placement('cancel_flow').show();
if (result.claim != null ||
result.publisher == EncorePublisherOutcome.purchased) {
grantAccess();
} else {
runOriginalAction(); // what onPassthrough used to do
}
Architecture #
Flutter App
│
▼
┌──────────────────────────────────────┐
│ Encore Dart API │
│ (lib/src/encore.dart) │
├──────────────────┬───────────────────┤
│ MethodChannel │ EventChannel │
│ com.encorekit/ │ com.encorekit/ │
│ encore │ encore/outcomes │
├──────────────────┼───────────────────┤
│ iOS Plugin │ Android Plugin │
│ (Swift) │ (Kotlin) │
├──────────────────┼───────────────────┤
│ EncoreKit │ com.encorekit │
│ CocoaPod │ :encore AAR │
└──────────────────┴───────────────────┘
The method channel carries request/response calls in both directions: the purchase controller is a reverse call, where native asks Dart to run the purchase and suspends until Dart answers. Dart cannot implement a Swift protocol or a Kotlin interface, so each plugin owns the native conformance and forwards over the channel.
Android's native controller is additionally handed the foreground Activity;
the plugin absorbs it — it is valid only for the duration of the call and must
not be retained, and iOS has no equivalent — so the Dart contract is identical
on both platforms.
The event channel carries the outcomes stream, which is one-way and unbounded
and so cannot ride a request/response method call. It bridges an AsyncStream
on iOS and a SharedFlow on Android onto one Dart broadcast Stream.
API Reference #
| Method | Description |
|---|---|
Encore.shared.configure(apiKey:, purchaseController:, logLevel:, unlock:) |
Initialize the SDK and register the purchase controller |
Encore.shared.identify(userId:, attributes:) |
Associate user identity |
Encore.shared.setUserAttributes(attributes) |
Merge user attributes |
Encore.shared.reset() |
Clear user data (logout); the purchase controller survives |
Encore.shared.setClaimEnabled(enabled) |
Enable/disable the claim CTA |
Encore.shared.outcomes |
Stream<EncorePlacementOutcome> of every resolved outcome |
Encore.placement(id).show() |
Present the native offer sheet; returns EncorePresentationResult |
Encore.placement(id).useCase(useCase) |
Select what the placement should achieve: EncoreUseCase.reduceChurn (default) or .rewardUsers |
Encore.placement(id).headline(text) |
Override the sheet headline, on every use case |
Encore.placement(id).subheadline(text) |
Override the sheet subheadline, on every use case |