kitty_sdk 0.13.2
kitty_sdk: ^0.13.2 copied to clipboard
Kitty Flutter SDK — device fingerprint attribution (Flare) plus authentication, user management, licenses and subscriptions (Account) for the Kitty Launch platform.
Kitty Flutter SDK #
Unified Flutter SDK for the Kitty Launch platform — combines Flare (device fingerprint attribution) and Account (authentication, user management, licenses, subscriptions) in a single package.
Install #
dependencies:
kitty_sdk: ^0.13.1
Requires Flutter >=3.38.0 and Dart >=3.10.0. Coming from 0.12.x? Read
Upgrading to 0.13.0 below first.
Upgrading to 0.13.0 #
Use 0.13.1 rather than 0.13.0. It fixes two purchase defects present in 0.13.0: KittyBuyNow purchases now work on Android (broken since 0.10.0), and the paywall's pre-purchase cleanup no longer finishes a renewal whose own claim is still held. Everything below applies to both versions.
New minimums: Flutter >=3.38.0, Dart >=3.10.0 and
in_app_purchase_storekit ^0.4.10. The SDK depends on the plugin directly, so
if your app pins it, allow 0.4.10 or later.
On iOS the SDK finishes App Store transactions, and only after the backend
confirms them. While the App Store observer runs (enableAppStoreObserver: true, the default), the SDK finishes iOS transactions, not your app:
- A purchase is finished once the Kitty backend confirms it. Until then it stays unfinished, and StoreKit redelivers it on the next launch, where the SDK tries again. A purchase can therefore stay held across launches. Failures that may be temporary (offline, timeouts, 5xx, 429) just keep it held. A purchase the backend rejects or does not confirm is finished with an error on the fifth such launch.
- As before, a signed-out buyer is logged in automatically from the purchase. The purchase then goes through the same confirmation. It is finished without confirmation only when that login is refused outright (404, or another 4xx except 429).
- If your app also listens to
InAppPurchase.instance.purchaseStream, do not callcompletePurchasefor iOS App Store purchases while the observer runs. Finishing early discards the receipt before the backend has recorded it. - Android purchase handling is unchanged: the paywalls still finish Google Play
purchases immediately. KittyBuyNow purchases (
InAppBuyNowBlocand the web paywall'sBuyNowMessageHandler) now work on Android in 0.13.1. From 0.10.0 through 0.13.0 they failed withPlatformException(channel-error)before reaching Google Play.
Other changes a host app can notice:
onAppStoreAutoClaimnow also fires after an anonymous auto-login, because the SDK claims the purchase right after logging in.onAppStoreObserverErrornow also receives aStateErrorwhen a purchase is given up. It also receives aTimeoutExceptionwhen a StoreKit finish (10 s), an auto-login or a claim (30 s) times out. Timed-out work is retried on the next launch.- New analytics event
AppStoreClaimGivenUp(productId,attempts). New SharedPreferences keykitty_purchase_claim_attempts(a per-transaction retry counter). ReauthRequiredExceptionnow means the session is really gone, and it has a new optionaloriginStatusCode. A temporary refresh failure now surfaces asApiException,TimeoutException, a transport error, or a parse error (FormatException/TypeError). Do not treat those as a logout. When a logout races a request, it surfaces withisCredentialsAbsent == trueand nooriginStatusCode.SessionSupersededExceptioncan reach calls made throughKittyAccount.authorizedHttpClientorKittySdk.tokens. This happens in a rare race where a login overtakes a token refresh. Catch it by type (on SessionSupersededException) and retry.- When the user id is a UUID, signed-in iOS purchases now carry it as
appAccountToken(StoreKit 2) orapplicationUserName(StoreKit 1). - A login that replaces a signed-in user now drops the previous account at once
and emits
nullonaccountStreambefore the new account. If the new account then cannot be fetched (a 5xx or network error), the login throws and the app reads as signed out (isAuthenticatedfalse,currentAccountnull), but the new credentials are kept:refreshAccountInfo(), or the next launch, completes the session. Until then, purchases are handled as signed out.
Known limitations:
- While a purchase is held, buying the same product again hits StoreKit's
duplicate-purchase guard, and the SDK reports "purchase pending". The built-in
flows show no message:
InAppBuyNowBlocreturns to the product list as it does on a cancel, andBuyNowMessageHandlercloses the web paywall. - Only a running observer confirms transactions. In three setups the paywall's
pre-purchase cleanup no longer finishes leftover transactions:
enableAppStoreObserver: false, KittyBuyNow without KittyAccount, and macOS. There, a leftover transaction for the product being bought ends in the same silent "pending". - After a login, the SDK at once re-claims subscriptions and non-consumables bought before it. A held consumable, such as a coin pack, is claimed when StoreKit redelivers it on the next launch.
- Confirmation needs StoreKit 2, which is the plugin's default. If your app opts into StoreKit 1, signed-in purchases cannot be confirmed. They are finished with an error on the fifth launch.
- A purchase processed while a login is still loading its account takes the
signed-out path. From the moment a login stores its credentials until its
account fetch succeeds (after a failed fetch, until
refreshAccountInfo()succeeds), an iOS purchase is not claimed for the user who just signed in. The SDK auto-logs in with the purchase's own App Store account instead: the purchase is credited to that account, and its session replaces the credentials just stored. This already applied to a login from a signed-out state; since 0.13.0 it also applies when a login replaces a signed-in user. A follow-up release claims under the stored Kitty session instead.
Quick Start #
import 'package:kitty_sdk/kitty_sdk.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await KittySdk.initialize(
config: KittySdkConfig(
projectId: 'proj_xxx',
accountBaseUrl: '<your-account-api-url>',
flareBaseUrl: '<your-flare-api-url>',
),
);
runApp(const MyApp());
}
Usage #
Flare — fingerprint identification #
final match = await KittySdk.flare.identify();
if (match != null) {
print('Matched! uid=${match.uid} type=${match.matchType}');
}
Account — authentication #
final account = await KittySdk.account.loginWithPassword(
email: 'user@example.com',
password: 'secret',
);
KittySdk.account.accountStream.listen((account) {
// React to auth state changes
});
For an app that keeps a separate legacy login, check project membership before choosing the login flow:
final useKitty = await KittySdk.account.isUserInProject(
email: email,
);
if (useKitty) {
// Show the Kitty login flow.
} else {
// Show the legacy login flow.
}
This checks for a Kitty project relation or license, including an expired one.
It does not authenticate the email. Handle lookup errors separately; an API
failure must not be treated as false. For Apple sign-in, perform provider
routing after the Apple identity has been verified by the app's backend.
Account — licenses #
final user = KittySdk.account.currentAccount;
final license = user?.bestLicense;
if (license != null && license.isActive) {
// User has active license
}
Account — feature access (entitlements) #
Gate features on the entitlement keys the billing plan declares
(premium_stories, hd_export, …) rather than on plan names. The account-level
list is the server's answer — the SDK reports it as given and never re-derives
it from the licenses.
App Store review accounts are an exception: when the loaded account's effective
email is test@<domain>, hasEntitlement(key) returns true for every key,
regardless of licenses, expiration, or the server's entitlement list. Email
matching is case-insensitive and ignores surrounding whitespace. The effective
email is primaryEmail, falling back to the first entry in emails; a secondary
test email does not override an ordinary primary email. This applies to the
entitlement helper only: the raw entitlements list and license state stay as
returned by the server.
The gate is tri-state:
null— no account is loaded: a fresh install before the first login, or afterlogout(). Fail closed and keep the feature locked until you know.true/false— the most recently loaded account. Startup awaits a refresh when a session is restored, so normally this is the server's answer. If that startup refresh fails — an offline launch — the SDK keeps the persisted cache, so afalsemay be a stale cached answer rather than the server's current one.
The SDK does not retry a failed startup refresh on its own. Call
refreshAccountInfo() yourself — on app resume, for instance — if the gate has
to recover from an offline launch. A cache written by an SDK version before
0.12.0 rehydrates with no entitlements, so a user who does hold a key reads
false until such a refresh lands. That is deliberate: it fails closed.
switch (KittySdk.account.hasEntitlement('premium_stories')) {
case true:
showPremiumStories();
case false:
showPaywall();
case null:
showLoading(); // not known yet — never treat this as granted
}
// All keys the account holds, or null when no account is loaded.
final keys = KittySdk.account.entitlements;
Rebuild on accountStream rather than reading this once at startup — that is
how the UI picks up a later refresh, whether it comes from a login, a purchase,
or your own refreshAccountInfo() call.
Account UI — theming #
Set the base SDK UI theme once:
await KittySdk.initialize(
config: KittySdkConfig(
projectId: 'proj_xxx',
accountBaseUrl: '...',
flareBaseUrl: '...',
theme: KittyTheme(
themeData: ThemeData(
useMaterial3: true,
fontFamily: 'DM Sans',
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
),
),
),
);
Customize auth and bind-email screens separately:
final authTheme = KittyAuthTheme(
backgroundColor: Colors.white,
loginHeaderWidget: Image.asset('assets/logo.png', height: 64),
codeHeaderWidget: Image.asset('assets/logo.png', height: 48),
loginBodyPadding: EdgeInsets.symmetric(horizontal: 24),
primaryButtonStyle: ElevatedButton.styleFrom(
minimumSize: Size(double.infinity, 48),
shape: StadiumBorder(),
),
inputDecoration: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
);
KittyAuthFlow(
account: KittySdk.account,
theme: authTheme,
);
KittyBindEmailFlow(
account: KittySdk.account,
theme: authTheme,
);
Customize settings screens by logical blocks:
KittySettingsFlow(
account: KittySdk.account,
onLoginPressed: () {},
onRenewSubscription: () {},
theme: KittySettingsTheme(
page: KittySettingsPageTheme(
backgroundColor: Colors.white,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 24),
),
menuItem: KittySettingsMenuItemTheme(
borderRadius: BorderRadius.all(Radius.circular(16)),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
iconColor: Colors.black54,
),
buttons: KittySettingsButtonsTheme(
primaryButtonSize: Size(240, 52),
primaryButtonStyle: ElevatedButton.styleFrom(
shape: StadiumBorder(),
),
),
dialog: KittySettingsDialogTheme(
destructiveActionStyle: TextStyle(color: Colors.red),
),
bindEmail: KittySettingsBindEmailTheme(
authTheme: authTheme,
),
),
);
All theme fields are optional. If a field is null, the SDK uses its default style from Theme.of(context) / the built-in Kitty UI.
Buy Now — web paywall with mobile prices #
Requires Common Module v13.4.0+ on the web side.
final products = <ProductDetails>{...}; // from InAppPurchase
router.push(MaterialPageRoute(
builder: (_) => KittyWebBuyNowPage(
url: 'https://your-domain.com/buy-now', // Kitty Flow url
products: products,
onPaymentSuccess: () {
router.pop();
},
),
));
Modules #
| Module | Access | Description |
|---|---|---|
| Flare | KittySdk.flare |
Device fingerprint identification & purchase attribution |
| Account | KittySdk.account |
Auth, user management, licenses, subscriptions |
| Tokens | KittySdk.tokens |
Token balance and operations (requires Account) |
| Buy Now | KittySdk.buyNow |
Web paywall with mobile price bridging |
Tokens — balance and operations #
// 1. Enable tokens in KittySdkConfig
await KittySdk.initialize(
config: KittySdkConfig(
projectId: 'proj_xxx',
accountBaseUrl: '...',
flareBaseUrl: '...',
tokens: const KittyTokensConfig(isDebug: true),
),
);
// 2. Use the module (user must be logged in via KittySdk.account)
// By default the SDK uses the first usable license of the current user.
// Pass `licenseId:` explicitly when you need to target a specific license.
final balance = await KittySdk.tokens.getBalance();
print('Tokens: ${balance.balance}');
// Get operations history
final ops = await KittySdk.tokens.getOperations(skip: 0, limit: 20);
// Token mutations (add/remove) are server-side only — call them from your
// backend with an internal API key or a per-project `kap_` bearer token
// against `POST /api/internal/tokens/add` and `/api/internal/tokens/remove`.
// 3. Use the Cubit in your UI
BlocProvider(
create: (_) => TokensCubit(tokens: KittySdk.tokens)..loadBalance(),
child: BlocBuilder<TokensCubit, TokensState>(
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
return Text('Balance: ${state.tokenCount}');
},
),
)
Backend token mutations — important notes
add/remove are server-side only (call them from your backend with a per-project
kap_ bearer token). Before you build against them, mind these three behaviours
(full guide: kitty-launch-account/docs/backend-token-api.md):
/addand/removehit DIFFERENT buckets./addcredits the persistentbalancebucket;/removedebits the renewablesubscriptionBalancebucket (which has no floor and can go negative). A naive "grant then spend" will not net out — for a "tokens per operation" model, confirm with the platform team which bucket you should be debiting.- No idempotency on
/add//remove. Calling twice applies twice. Deduplicate on your side (fire exactly once per operation, e.g. guard by your own operation id). - A
kap_token cannot read balance.GET /balanceandGET /operationsrequire the internal API key — akap_bearer gets 401 there. Decide how your backend will verify results (e.g. read balance in the client via the SDK, or request read access from the platform team).